Repository: postalserver/postal
Branch: main
Commit: d532922ff7f5
Files: 525
Total size: 1.2 MB
Directory structure:
gitextract_spx0x18a/
├── .dockerignore
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── config.yml
│ └── workflows/
│ ├── ci.yml
│ └── close.yml
├── .gitignore
├── .release-please-manifest.json
├── .rubocop.yml
├── .ruby-version
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── Gemfile
├── MIT-LICENCE
├── Procfile.dev
├── README.md
├── Rakefile
├── SECURITY.md
├── app/
│ ├── assets/
│ │ ├── config/
│ │ │ └── manifest.js
│ │ ├── images/
│ │ │ └── .keep
│ │ ├── javascripts/
│ │ │ └── application/
│ │ │ ├── application.coffee
│ │ │ ├── elements/
│ │ │ │ ├── ajax.coffee
│ │ │ │ ├── mail_graph.coffee
│ │ │ │ ├── remembering.coffee
│ │ │ │ └── searchable.coffee
│ │ │ └── vendor/
│ │ │ ├── chartist.js
│ │ │ └── jquery.multibox.js
│ │ └── stylesheets/
│ │ └── application/
│ │ ├── application.scss
│ │ ├── components/
│ │ │ ├── _admin_stats.scss
│ │ │ ├── _button_set.scss
│ │ │ ├── _checkbox_list.scss
│ │ │ ├── _credential_list.scss
│ │ │ ├── _danger_zone.scss
│ │ │ ├── _data_table.scss
│ │ │ ├── _delivery_list.scss
│ │ │ ├── _domain_list.scss
│ │ │ ├── _endpoint_list.scss
│ │ │ ├── _error_explanation.scss
│ │ │ ├── _field_set.scss
│ │ │ ├── _flash_display.scss
│ │ │ ├── _flash_message.scss
│ │ │ ├── _footer.scss
│ │ │ ├── _grid.scss
│ │ │ ├── _headers_list.scss
│ │ │ ├── _inlineError.scss
│ │ │ ├── _invoice_list.scss
│ │ │ ├── _ip_list.scss
│ │ │ ├── _ip_pool_rule_list.scss
│ │ │ ├── _large_list.scss
│ │ │ ├── _limit.scss
│ │ │ ├── _login_form.scss
│ │ │ ├── _mail_graph.scss
│ │ │ ├── _message_activity.scss
│ │ │ ├── _message_header.scss
│ │ │ ├── _message_list.scss
│ │ │ ├── _message_properties_page.scss
│ │ │ ├── _message_search.scss
│ │ │ ├── _multibox.scss
│ │ │ ├── _nav_bar.scss
│ │ │ ├── _new_message_type.scss
│ │ │ ├── _no_data.scss
│ │ │ ├── _page_content.scss
│ │ │ ├── _page_header.scss
│ │ │ ├── _pagination.scss
│ │ │ ├── _rentention_limits.scss
│ │ │ ├── _route_list.scss
│ │ │ ├── _route_name_input.scss
│ │ │ ├── _server_header.scss
│ │ │ ├── _sidebar.scss
│ │ │ ├── _sidebar_server_list.scss
│ │ │ ├── _simple_pagination.scss
│ │ │ ├── _site_content.scss
│ │ │ ├── _site_header.scss
│ │ │ ├── _spam_check_list.scss
│ │ │ ├── _starter_credit_pack.scss
│ │ │ ├── _sub_page_box.scss
│ │ │ ├── _suppression_list.scss
│ │ │ ├── _suspension_box.scss
│ │ │ ├── _title_with_links.scss
│ │ │ ├── _user_list.scss
│ │ │ ├── _webhook_list.scss
│ │ │ └── _webhook_request_list.scss
│ │ ├── elements/
│ │ │ ├── _bar.scss
│ │ │ ├── _button.scss
│ │ │ ├── _code_block.scss
│ │ │ ├── _input.scss
│ │ │ ├── _label.scss
│ │ │ ├── _misc.scss
│ │ │ └── _spam_range.scss
│ │ ├── global/
│ │ │ ├── _fonts.scss
│ │ │ ├── _mixins.scss
│ │ │ ├── _reset.scss
│ │ │ ├── _utility.scss
│ │ │ └── _variables.scss
│ │ └── vendor/
│ │ └── _chartist.scss
│ ├── controllers/
│ │ ├── address_endpoints_controller.rb
│ │ ├── application_controller.rb
│ │ ├── concerns/
│ │ │ ├── .keep
│ │ │ └── within_organization.rb
│ │ ├── credentials_controller.rb
│ │ ├── domains_controller.rb
│ │ ├── help_controller.rb
│ │ ├── http_endpoints_controller.rb
│ │ ├── ip_addresses_controller.rb
│ │ ├── ip_pool_rules_controller.rb
│ │ ├── ip_pools_controller.rb
│ │ ├── legacy_api/
│ │ │ ├── base_controller.rb
│ │ │ ├── messages_controller.rb
│ │ │ └── send_controller.rb
│ │ ├── messages_controller.rb
│ │ ├── organization_ip_pools_controller.rb
│ │ ├── organizations_controller.rb
│ │ ├── routes_controller.rb
│ │ ├── servers_controller.rb
│ │ ├── sessions_controller.rb
│ │ ├── smtp_endpoints_controller.rb
│ │ ├── track_domains_controller.rb
│ │ ├── user_controller.rb
│ │ ├── users_controller.rb
│ │ ├── webhooks_controller.rb
│ │ └── well_known_controller.rb
│ ├── helpers/
│ │ └── application_helper.rb
│ ├── lib/
│ │ ├── dkim_header.rb
│ │ ├── dns_resolver.rb
│ │ ├── message_dequeuer/
│ │ │ ├── base.rb
│ │ │ ├── incoming_message_processor.rb
│ │ │ ├── initial_processor.rb
│ │ │ ├── outgoing_message_processor.rb
│ │ │ ├── single_message_processor.rb
│ │ │ └── state.rb
│ │ ├── message_dequeuer.rb
│ │ ├── query_string.rb
│ │ ├── received_header.rb
│ │ ├── reply_separator.rb
│ │ ├── smtp_client/
│ │ │ ├── endpoint.rb
│ │ │ ├── server.rb
│ │ │ └── ssl_modes.rb
│ │ ├── smtp_server/
│ │ │ ├── client.rb
│ │ │ └── server.rb
│ │ └── worker/
│ │ ├── jobs/
│ │ │ ├── base_job.rb
│ │ │ ├── process_queued_messages_job.rb
│ │ │ └── process_webhook_requests_job.rb
│ │ └── process.rb
│ ├── mailers/
│ │ ├── app_mailer.rb
│ │ └── application_mailer.rb
│ ├── models/
│ │ ├── additional_route_endpoint.rb
│ │ ├── address_endpoint.rb
│ │ ├── application_record.rb
│ │ ├── bounce_message.rb
│ │ ├── concerns/
│ │ │ ├── .keep
│ │ │ ├── has_authentication.rb
│ │ │ ├── has_dns_checks.rb
│ │ │ ├── has_locking.rb
│ │ │ ├── has_message.rb
│ │ │ ├── has_soft_destroy.rb
│ │ │ └── has_uuid.rb
│ │ ├── credential.rb
│ │ ├── domain.rb
│ │ ├── http_endpoint.rb
│ │ ├── incoming_message_prototype.rb
│ │ ├── ip_address.rb
│ │ ├── ip_pool.rb
│ │ ├── ip_pool_rule.rb
│ │ ├── organization.rb
│ │ ├── organization_ip_pool.rb
│ │ ├── organization_user.rb
│ │ ├── outgoing_message_prototype.rb
│ │ ├── queued_message.rb
│ │ ├── route.rb
│ │ ├── scheduled_task.rb
│ │ ├── server.rb
│ │ ├── smtp_endpoint.rb
│ │ ├── statistic.rb
│ │ ├── track_domain.rb
│ │ ├── user.rb
│ │ ├── user_invite.rb
│ │ ├── webhook.rb
│ │ ├── webhook_event.rb
│ │ ├── webhook_request.rb
│ │ └── worker_role.rb
│ ├── scheduled_tasks/
│ │ ├── action_deletions_scheduled_task.rb
│ │ ├── application_scheduled_task.rb
│ │ ├── check_all_dns_scheduled_task.rb
│ │ ├── cleanup_authie_sessions_scheduled_task.rb
│ │ ├── expire_held_messages_scheduled_task.rb
│ │ ├── process_message_retention_scheduled_task.rb
│ │ ├── prune_suppression_lists_scheduled_task.rb
│ │ ├── prune_webhook_requests_scheduled_task.rb
│ │ ├── send_notifications_scheduled_task.rb
│ │ └── tidy_queued_messages_task.rb
│ ├── senders/
│ │ ├── base_sender.rb
│ │ ├── http_sender.rb
│ │ ├── send_result.rb
│ │ └── smtp_sender.rb
│ ├── services/
│ │ └── webhook_delivery_service.rb
│ ├── util/
│ │ ├── has_prometheus_metrics.rb
│ │ ├── health_server.rb
│ │ └── user_creator.rb
│ └── views/
│ ├── address_endpoints/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── app_mailer/
│ │ ├── password_reset.text.erb
│ │ ├── server_send_limit_approaching.text.erb
│ │ ├── server_send_limit_exceeded.text.erb
│ │ ├── server_suspended.text.erb
│ │ ├── test_message.text.erb
│ │ └── verify_domain.text.erb
│ ├── credentials/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── domains/
│ │ ├── _nav.html.haml
│ │ ├── _verify_with_dns.html.haml
│ │ ├── _verify_with_email.html.haml
│ │ ├── index.html.haml
│ │ ├── new.html.haml
│ │ ├── setup.html.haml
│ │ └── verify.html.haml
│ ├── help/
│ │ ├── _header.html.haml
│ │ ├── incoming.html.haml
│ │ └── outgoing.html.haml
│ ├── http_endpoints/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── ip_addresses/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ └── new.html.haml
│ ├── ip_pool_rules/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── ip_pools/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── layouts/
│ │ ├── application.html.haml
│ │ └── sub.html.haml
│ ├── messages/
│ │ ├── _deliveries.html.haml
│ │ ├── _header.html.haml
│ │ ├── _index.html.haml
│ │ ├── _list.html.haml
│ │ ├── _message_header.html.haml
│ │ ├── _search.html.haml
│ │ ├── activity.html.haml
│ │ ├── attachments.html.haml
│ │ ├── headers.html.haml
│ │ ├── held.html.haml
│ │ ├── html.html.haml
│ │ ├── incoming.html.haml
│ │ ├── new.html.haml
│ │ ├── outgoing.html.haml
│ │ ├── plain.html.haml
│ │ ├── show.html.haml
│ │ ├── spam_checks.html.haml
│ │ └── suppressions.html.haml
│ ├── organization_ip_pools/
│ │ ├── _nav.html.haml
│ │ └── index.html.haml
│ ├── organizations/
│ │ ├── _nav.html.haml
│ │ ├── delete.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── routes/
│ │ ├── _form.html.haml
│ │ ├── _header.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── servers/
│ │ ├── _form.html.haml
│ │ ├── _header.html.haml
│ │ ├── _settings_header.html.haml
│ │ ├── _sidebar.html.haml
│ │ ├── advanced.html.haml
│ │ ├── delete.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ ├── limits.html.haml
│ │ ├── new.html.haml
│ │ ├── queue.html.haml
│ │ ├── retention.html.haml
│ │ ├── show.html.haml
│ │ └── spam.html.haml
│ ├── sessions/
│ │ ├── begin_password_reset.html.haml
│ │ ├── finish_password_reset.html.haml
│ │ └── new.html.haml
│ ├── shared/
│ │ └── _message_db_pagination.html.haml
│ ├── smtp_endpoints/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── track_domains/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ ├── user/
│ │ └── edit.html.haml
│ ├── users/
│ │ ├── _form.html.haml
│ │ ├── edit.html.haml
│ │ ├── index.html.haml
│ │ └── new.html.haml
│ └── webhooks/
│ ├── _form.html.haml
│ ├── _header.html.haml
│ ├── edit.html.haml
│ ├── history.html.haml
│ ├── history_request.html.haml
│ ├── index.html.haml
│ └── new.html.haml
├── bin/
│ ├── bundle
│ ├── dev
│ ├── postal
│ ├── rails
│ ├── rake
│ ├── rspec
│ ├── setup
│ └── update
├── config/
│ ├── application.rb
│ ├── boot.rb
│ ├── database.yml
│ ├── environment.rb
│ ├── environments/
│ │ ├── development.rb
│ │ ├── production.rb
│ │ └── test.rb
│ ├── examples/
│ │ ├── development.yml
│ │ └── test.yml
│ ├── initializers/
│ │ ├── _wait_for_migrations.rb
│ │ ├── application_controller_renderer.rb
│ │ ├── assets.rb
│ │ ├── backtrace_silencers.rb
│ │ ├── content_security_policy.rb
│ │ ├── cookies_serializer.rb
│ │ ├── filter_parameter_logging.rb
│ │ ├── inflections.rb
│ │ ├── logging.rb
│ │ ├── mail_extensions.rb
│ │ ├── mime_types.rb
│ │ ├── new_framework_defaults_7_0.rb
│ │ ├── omniauth.rb
│ │ ├── permissions_policy.rb
│ │ ├── postal.rb
│ │ ├── record_key_for_dom.rb
│ │ ├── secret_key.rb
│ │ ├── secure_headers.rb
│ │ ├── sentry.rb
│ │ ├── session_store.rb
│ │ ├── smtp.rb
│ │ ├── smtp_extensions.rb
│ │ ├── trusted_proxies.rb
│ │ ├── wrap_parameters.rb
│ │ └── zeitwerk.rb
│ ├── locales/
│ │ └── en.yml
│ ├── puma.rb
│ └── routes.rb
├── config.ru
├── db/
│ ├── migrate/
│ │ ├── 20161003195209_create_authie_sessions.authie.rb
│ │ ├── 20161003195210_add_indexes_to_authie_sessions.authie.rb
│ │ ├── 20161003195211_add_parent_id_to_authie_sessions.authie.rb
│ │ ├── 20161003195212_add_two_factor_auth_fields_to_authie.authie.rb
│ │ ├── 20170418200606_initial_schema.rb
│ │ ├── 20170421195414_add_token_hashes_to_authie_sessions.authie.rb
│ │ ├── 20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb
│ │ ├── 20170428153353_remove_type_from_ip_pools.rb
│ │ ├── 20180216114344_add_host_to_authie_sessions.authie.rb
│ │ ├── 20200717083943_add_uuid_to_credentials.rb
│ │ ├── 20210727210551_add_priority_to_ip_addresses.rb
│ │ ├── 20240206173036_add_privacy_mode_to_servers.rb
│ │ ├── 20240213165450_create_worker_roles.rb
│ │ ├── 20240213171830_create_scheduled_tasks.rb
│ │ ├── 20240214132253_add_lock_fields_to_webhook_requests.rb
│ │ ├── 20240223141500_add_two_factor_required_to_sessions.authie.rb
│ │ ├── 20240223141501_add_countries_to_authie_sessions.authie.rb
│ │ └── 20240311205229_add_oidc_fields_to_user.rb
│ ├── schema.rb
│ └── seeds.rb
├── doc/
│ └── config/
│ ├── configuration.md
│ ├── environment-variables.md
│ └── yaml.yml
├── docker/
│ ├── ci-config/
│ │ └── signing.key
│ └── wait-for.sh
├── docker-compose.yml
├── lib/
│ ├── assets/
│ │ └── .keep
│ ├── migration_waiter.rb
│ ├── postal/
│ │ ├── config.rb
│ │ ├── config_schema.rb
│ │ ├── error.rb
│ │ ├── helm_config_exporter.rb
│ │ ├── helpers.rb
│ │ ├── http.rb
│ │ ├── legacy_config_source.rb
│ │ ├── message_db/
│ │ │ ├── click.rb
│ │ │ ├── connection_pool.rb
│ │ │ ├── database.rb
│ │ │ ├── delivery.rb
│ │ │ ├── live_stats.rb
│ │ │ ├── load.rb
│ │ │ ├── message.rb
│ │ │ ├── migration.rb
│ │ │ ├── migrations/
│ │ │ │ ├── 01_create_migrations.rb
│ │ │ │ ├── 02_create_messages.rb
│ │ │ │ ├── 03_create_deliveries.rb
│ │ │ │ ├── 04_create_live_stats.rb
│ │ │ │ ├── 05_create_raw_message_sizes.rb
│ │ │ │ ├── 06_create_clicks.rb
│ │ │ │ ├── 07_create_loads.rb
│ │ │ │ ├── 08_create_stats.rb
│ │ │ │ ├── 09_create_links.rb
│ │ │ │ ├── 10_create_spam_checks.rb
│ │ │ │ ├── 11_add_time_to_deliveries.rb
│ │ │ │ ├── 12_add_hold_expiry.rb
│ │ │ │ ├── 13_add_index_to_message_status.rb
│ │ │ │ ├── 14_create_suppressions.rb
│ │ │ │ ├── 15_create_webhook_requests.rb
│ │ │ │ ├── 16_add_url_and_hook_to_webhooks.rb
│ │ │ │ ├── 17_add_replaced_link_count_to_messages.rb
│ │ │ │ ├── 18_add_endpoints_to_messages.rb
│ │ │ │ ├── 19_convert_database_to_utf8mb4.rb
│ │ │ │ └── 20_increase_links_url_size.rb
│ │ │ ├── provisioner.rb
│ │ │ ├── statistics.rb
│ │ │ ├── suppression_list.rb
│ │ │ └── webhooks.rb
│ │ ├── message_inspection.rb
│ │ ├── message_inspector.rb
│ │ ├── message_inspectors/
│ │ │ ├── clamav.rb
│ │ │ ├── rspamd.rb
│ │ │ └── spam_assassin.rb
│ │ ├── message_parser.rb
│ │ ├── signer.rb
│ │ ├── spam_check.rb
│ │ └── yaml_config_exporter.rb
│ ├── postal.rb
│ ├── tasks/
│ │ ├── .keep
│ │ ├── auto_annotate_models.rake
│ │ └── postal.rake
│ └── tracking_middleware.rb
├── log/
│ └── .keep
├── public/
│ ├── 404.html
│ ├── 422.html
│ ├── 500.html
│ └── robots.txt
├── release-please-config.json
├── resource/
│ └── postfix-bounce.msg
├── script/
│ ├── default_dkim_record.rb
│ ├── generate_tls_certificate.rb
│ ├── insert-bounce.rb
│ ├── make_user.rb
│ ├── queue_size.rb
│ ├── send_html_email.rb
│ ├── smtp_server.rb
│ ├── test_app_smtp.rb
│ ├── version.rb
│ └── worker.rb
├── spec/
│ ├── apis/
│ │ └── legacy_api/
│ │ ├── messages/
│ │ │ ├── deliveries_spec.rb
│ │ │ └── message_spec.rb
│ │ └── send/
│ │ ├── message_spec.rb
│ │ └── raw_spec.rb
│ ├── examples/
│ │ ├── dkim_signing/
│ │ │ ├── email1.msg
│ │ │ └── email2.msg
│ │ └── full_legacy_config_file.yml
│ ├── factories/
│ │ ├── address_endpoint_factory.rb
│ │ ├── credential_factory.rb
│ │ ├── domain_factory.rb
│ │ ├── http_endpoint_factory.rb
│ │ ├── ip_address_factory.rb
│ │ ├── ip_pool_factory.rb
│ │ ├── ip_pool_rule_factory.rb
│ │ ├── organization_factory.rb
│ │ ├── queued_message_factory.rb
│ │ ├── route_factory.rb
│ │ ├── server_factory.rb
│ │ ├── smtp_endpoint_factory.rb
│ │ ├── track_domain_factory.rb
│ │ ├── user_factory.rb
│ │ ├── webhook_factory.rb
│ │ ├── webhook_request_factory.rb
│ │ └── worker_role_factory.rb
│ ├── helpers/
│ │ ├── general_helpers.rb
│ │ ├── message_db_mocking.rb
│ │ ├── message_factory.rb
│ │ └── test_logger.rb
│ ├── lib/
│ │ ├── dkim_header_spec.rb
│ │ ├── dns_resolver_spec.rb
│ │ ├── message_dequeuer/
│ │ │ ├── base_spec.rb
│ │ │ ├── incoming_message_processor_spec.rb
│ │ │ ├── initial_message_processor_spec.rb
│ │ │ ├── outgoing_message_processor_spec.rb
│ │ │ ├── single_message_processor_spec.rb
│ │ │ └── state_spec.rb
│ │ ├── message_dequeuer_spec.rb
│ │ ├── postal/
│ │ │ ├── legacy_config_source_spec.rb
│ │ │ ├── message_db/
│ │ │ │ ├── connection_pool_spec.rb
│ │ │ │ └── database_spec.rb
│ │ │ ├── message_parser_spec.rb
│ │ │ └── signer_spec.rb
│ │ ├── postal_spec.rb
│ │ ├── query_string_spec.rb
│ │ ├── received_header_spec.rb
│ │ ├── smtp_client/
│ │ │ ├── endpoint_spec.rb
│ │ │ └── server_spec.rb
│ │ ├── smtp_server/
│ │ │ ├── client/
│ │ │ │ ├── auth_spec.rb
│ │ │ │ ├── data_spec.rb
│ │ │ │ ├── finished_spec.rb
│ │ │ │ ├── helo_spec.rb
│ │ │ │ ├── mail_from_spec.rb
│ │ │ │ ├── proxy_spec.rb
│ │ │ │ └── rcpt_to_spec.rb
│ │ │ └── client_spec.rb
│ │ └── worker/
│ │ └── jobs/
│ │ ├── process_queued_messages_job_spec.rb
│ │ └── process_webhook_requests_job_spec.rb
│ ├── models/
│ │ ├── domain_spec.rb
│ │ ├── organization_spec.rb
│ │ ├── outgoing_message_prototype_spec.rb
│ │ ├── queued_message_spec.rb
│ │ ├── server_spec.rb
│ │ ├── user/
│ │ │ ├── authentication_spec.rb
│ │ │ └── oidc_spec.rb
│ │ ├── user_spec.rb
│ │ └── worker_role_spec.rb
│ ├── rails_helper.rb
│ ├── scheduled_tasks/
│ │ └── tidy_queued_messages_task_spec.rb
│ ├── senders/
│ │ └── smtp_sender_spec.rb
│ ├── services/
│ │ └── webhook_delivery_service_spec.rb
│ └── spec_helper.rb
├── tmp/
│ └── .keep
└── vendor/
└── assets/
├── javascripts/
│ └── .keep
└── stylesheets/
└── .keep
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.byebug_history
.cache
.git/*
.github/*
.vscode/*
.yardoc/*
/.bundle
config/postal/*
doc/*
Dockerfile
log/*
node_modules
Procfile.local
Procfile*
public/assets
storage/*
tmp/*
vendor/bundle
vendor/bundle
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: 🐛 Bug report
about: Create a report to help us improve Postal and fix issues.
title: ''
labels: ''
assignees: ''
---
## Describe the bug
A clear and concise description of what the bug is.
## To Reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected behaviour
A clear and concise description of what you expected to happen.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment details
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- Type [e.g. desktop, mobile etc...]
## Additional information/context
Add any other context about the problem here. It is particularily useful to include log extracts (after removing private information).
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
- name: 💻 Installation help
url: https://github.com/postalhq/postal/discussions/new?category=Installation-help
about: If you have questions about running Postal on your servers, use GitHub Discussions.
- name: 🙏 Help with using Postal
url: https://github.com/postalserver/postal/discussions/new?category=Help-with-using-Postal
about: If you need help using Postal's features, use GitHub Discussions.
- name: 🦊 Feature Suggestions
url: https://github.com/postalhq/postal/discussions/new?category=Feature-Suggestions
about: Suggest a new feature that should be added to Postal.
================================================
FILE: .github/workflows/ci.yml
================================================
---
name: CI
on: [push]
jobs:
release-please:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
outputs:
release_created: ${{ steps.release-please.outputs.release_created }}
tag_name: ${{ steps.release-please.outputs.tag_name }} # e.g. v1.0.0
version: ${{ steps.release-please.outputs.version }} # e.g. 1.0.0
all: ${{ toJSON(steps.release-please.outputs) }}
steps:
- uses: google-github-actions/release-please-action@v3
id: release-please
with:
command: manifest
build:
name: CI Image Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v4
with:
push: true
tags: ghcr.io/postalserver/postal:ci-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: ci
platforms: linux/amd64
test:
name: Test Suite
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: docker compose pull
env:
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
- run: docker compose run postal sh -c 'bundle exec rspec'
env:
POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }}
release-branch:
name: Release (branch)
runs-on: ubuntu-latest
needs: [build]
if: >-
startsWith(github.ref, 'refs/heads/') &&
startsWith(github.ref, 'refs/heads/release-please-') != true &&
startsWith(github.ref, 'refs/heads/dependabot/') != true
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: info
run: |
IMAGE=ghcr.io/postalserver/postal
REF="${GITHUB_REF#refs/heads/}"
if [ -z "$REF" ]; then exit 1; fi
VER="$(git describe --tags 2>/dev/null)"
echo "version=${VER}" >> "$GITHUB_OUTPUT"
echo "branch=${REF}" >> "$GITHUB_OUTPUT"
echo 'tags<> "$GITHUB_OUTPUT"
if [[ "$REF" == "main" ]]; then
echo "${IMAGE}:latest" >> "$GITHUB_OUTPUT"
else
echo "${IMAGE}:branch-${REF}" >> "$GITHUB_OUTPUT"
fi
echo 'EOF' >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v4
with:
push: true
tags: ${{ steps.info.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: full
platforms: linux/amd64
build-args: |
VERSION=${{ steps.info.outputs.version }}
BRANCH=${{ steps.info.outputs.branch }}
publish-image:
name: Publish Image
runs-on: ubuntu-latest
needs: [build, test, release-please]
if: ${{ needs.release-please.outputs.release_created }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v2
- uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v4
with:
push: true
tags: |
ghcr.io/postalserver/postal:stable
ghcr.io/postalserver/postal:${{ needs.release-please.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: full
build-args: |
VERSION=${{ needs.release-please.outputs.version }}
platforms: linux/amd64
================================================
FILE: .github/workflows/close.yml
================================================
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.'
close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.'
close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.'
stale-issue-label: stale
stale-pr-label: stale
days-before-issue-stale: 30
days-before-pr-stale: 45
days-before-issue-close: 5
days-before-pr-close: 10
exempt-all-assignees: true
exempt-all-milestones: true
exempt-issue-labels: bug,enhancement,docs,install,feature
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# If you find yourself ignoring temporary files generated by your text editor
# or operating system, you probably want to add a global ignore instead:
# git config --global core.excludesfile '~/.gitignore_global'
# Ignore bundler config.
/.bundle
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore Byebug command history file.
.byebug_history
config/postal.yml
config/smtp.cert
config/smtp.key
config/signing.key
config/postal/**/*
spec/config/postal.local.yml
public/assets
vendor/bundle
Procfile.local
VERSION
BRANCH
.rubocop-https*
.env*
================================================
FILE: .release-please-manifest.json
================================================
{
".": "3.3.5"
}
================================================
FILE: .rubocop.yml
================================================
AllCops:
TargetRubyVersion: 3.0
NewCops: enable
Exclude:
- "bin/*"
- "db/schema.rb"
# Fixes missing gem exception when running Rubocop on GitHub Actions.
- "vendor/bundle/**/*"
- lib/tasks/auto_annotate_models.rake
# Always use double quotes
Style/StringLiterals:
EnforcedStyle: double_quotes
AutoCorrect: true
# We prefer arrays of symbols to look like an array of symbols.
# For example: [:one, :two, :three] as opposed to %i[one two three]
Style/SymbolArray:
EnforcedStyle: brackets
# There should always be empty lines inside a class. For example
#
# class MyExample
#
# def some_method
# end
#
# end
Layout/EmptyLinesAroundClassBody:
EnforcedStyle: empty_lines
# We want to keep attr_* definitions separated on their own lines, rather than
# all of them collapsed into a single attr_* call. The collapsed/grouped variant
# is harder to read, and harder to see what's been changed in diffs.
Style/AccessorGrouping:
Enabled: false
# Blocks are slightly different to classes, in these cases there should
# not be new lines around the contents of the block.
#
# proc do
# # Do something
# end
Layout/EmptyLinesAroundBlockBody:
EnforcedStyle: no_empty_lines
# Modules are the same as classes unless they're being used for namespacing
# purposes in which case there should not be new lines.
Layout/EmptyLinesAroundModuleBody:
EnforcedStyle: empty_lines_except_namespace
# Space is required following -> when writing a lambda:
#
# somethign = -> (var) { block }
Layout/SpaceInLambdaLiteral:
EnforcedStyle: require_space
Layout/FirstHashElementIndentation:
Enabled: false
# We don't mind setting assignments in conditions so this has been disabled to
# allow `if something = something_else` without worrying about brackets.
Lint/AssignmentInCondition:
Enabled: false
# Top level documentation is quite rare...
Style/Documentation:
Enabled: false
# We want to allow inner slashes in a regexp to be used when using /xxx/ form.
Style/RegexpLiteral:
AllowInnerSlashes: true
# Blocks of if statements are perfectly fine and usually more readable than
# putting everything onto a single line just because we can.
Style/IfUnlessModifier:
Enabled: false
# We prefer assignments to happen within the condition rather than setting a
# variable to the result of a condition.
Style/ConditionalAssignment:
EnforcedStyle: assign_inside_condition
IncludeTernaryExpressions: false
# Empty methods should not be compacted onto a single line
Style/EmptyMethod:
EnforcedStyle: expanded
# As above, just flag them.
Lint/UnusedBlockArgument:
AutoCorrect: false
# While we don't want to make heavy use of get_ or set_ methods we do often need
# to use these when we want to refer to actually getting or setting something
# (usually from another API).
Naming/AccessorMethodName:
Enabled: false
# If we want a boolean called :true, we should be allowed that. These are likely
# not mistakes.
Lint/BooleanSymbol:
Enabled: false
# Using block.map(&:upcase) is not always the neatest way to show something. For
# example if you have a block that just calls one thing, you don't want it
# collapsed.
#
# action do |user|
# user.delete
# end
#
# This should be action(&:delete) because it is not clear what is actually
# happening without the context of knowing what the inner variable should be
# called.
Style/SymbolProc:
Enabled: false
# Allow a maxmium of 5 arguments and don't include keyword arguments
Metrics/ParameterLists:
Max: 5
CountKeywordArgs: false
# This cop checks for chaining of a block after another block that spans multiple lines.
Style/MultilineBlockChain:
Exclude:
- "spec/**/*.rb"
Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: consistent_comma
Metrics/AbcSize:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: true
SafeAutoCorrect: true
Naming/PredicateName:
Enabled: false
Layout/LineLength:
# We want to reduce this back down to 120 but there are a fair number of offences
# of this which need addressing individually and carefully.
Max: 200
Metrics/PerceivedComplexity:
# As above, we want to enable this again in the future, but for now we'll just
# disable it entirely.
Enabled: false
Metrics/CyclomaticComplexity:
# As above.
Enabled: false
Metrics/MethodLength:
# As above.
Enabled: false
Metrics/BlockNesting:
# As above.
Enabled: false
Style/StringConcatenation:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/ClassLength:
Enabled: false
Metrics/ModuleLength:
Enabled: false
Lint/UnusedMethodArgument:
Enabled: false
Style/SpecialGlobalVars:
Enabled: false
================================================
FILE: .ruby-version
================================================
3.4.6
================================================
FILE: CHANGELOG.md
================================================
# CHANGELOG
This file contains all the latest changes and updates to Postal.
## [3.3.5](https://github.com/postalserver/postal/compare/3.3.4...3.3.5) (2026-02-01)
### Bug Fixes
* **deliveries:** escape delivery details to prevent HTML injection ([11419f9](https://github.com/postalserver/postal/commit/11419f99140e13688a9613cab3ee03f8d3cbae45))
* **health_server:** use rackup handler instead of rack handler ([7c47422](https://github.com/postalserver/postal/commit/7c47422c865e738c4d6af0fed1cca4405288341f))
* oidc scopes are invalid when concatenated ([#3332](https://github.com/postalserver/postal/issues/3332)) ([9c5f96a](https://github.com/postalserver/postal/commit/9c5f96ae90cf06dcd5db776806865752f667bd95))
* typo in process logging ([#3212](https://github.com/postalserver/postal/issues/3212)) ([b7e5232](https://github.com/postalserver/postal/commit/b7e5232e077b3c9b7a999dcb6676fba0ec61458e))
* typo in the credentials page ([fd3c7cc](https://github.com/postalserver/postal/commit/fd3c7ccdf6dc4ee0a76c9523cbd735159e4b8000))
* update url for v2 config ([#3225](https://github.com/postalserver/postal/issues/3225)) ([e00098b](https://github.com/postalserver/postal/commit/e00098b8003cf37f2708f536871b3ade377aed2d))
### Documentation
* **process.rb:** add help about time unit used by metric ([#3339](https://github.com/postalserver/postal/issues/3339)) ([f5325c4](https://github.com/postalserver/postal/commit/f5325c49ff1152ad53eaaec98717ad3412d379ae))
### Miscellaneous Chores
* **deps:** upgrade puma, net-imap and other deps ([c03c44b](https://github.com/postalserver/postal/commit/c03c44b442a29aa9881c1e1aae60bead9776a6b6))
* **dockerfile:** reduce container size ([86de372](https://github.com/postalserver/postal/commit/86de372382bd62bdd5d1372254f8817b0360bd56))
* remove version from docker-compose.yml ([c78000c](https://github.com/postalserver/postal/commit/c78000ca8f2998aa04648f465060768db6467de6))
* upgrade resolv to 0.6.2 ([d00d978](https://github.com/postalserver/postal/commit/d00d978872a96369544303d08f6a9d11cdf56b62))
* upgrade to rails 7.1 and ruby 3.4 ([#3457](https://github.com/postalserver/postal/issues/3457)) ([ab6d443](https://github.com/postalserver/postal/commit/ab6d4430baa33a05f1aa66e776cc2a5bcaa0ede8))
* upgrade uri gem to 1.0.3 ([f193b8e](https://github.com/postalserver/postal/commit/f193b8e77fc096382ab7aaa6a2c29641b4cb12df))
## [3.3.4](https://github.com/postalserver/postal/compare/3.3.3...3.3.4) (2024-06-20)
### Bug Fixes
* fix `postal version` command ([4fa88ac](https://github.com/postalserver/postal/commit/4fa88acea0dececd0eae485506a2ad8268fbea59))
* fix issue running message pruning task ([3a33e53](https://github.com/postalserver/postal/commit/3a33e53d843584757bb00898746aa059d7616db4))
* raise NotImplementedError when no call method on a scheduled task ([2b0919c](https://github.com/postalserver/postal/commit/2b0919c1454eabea93db96f50ecbd8e36bb89f1f))
## [3.3.3](https://github.com/postalserver/postal/compare/3.3.2...3.3.3) (2024-04-18)
### Bug Fixes
* **legacy-api:** allow _expansions to be provided as true to return all expansions ([39f704c](https://github.com/postalserver/postal/commit/39f704c256fc3e71a1dc009acc77796a1efffead)), closes [#2932](https://github.com/postalserver/postal/issues/2932)
## [3.3.2](https://github.com/postalserver/postal/compare/3.3.1...3.3.2) (2024-03-21)
### Code Refactoring
* **versioning:** improve how current version and branch is determined and set ([07c6b31](https://github.com/postalserver/postal/commit/07c6b317f2b9dc04b6a8c88df1e6aa9e54597504))
## [3.3.1](https://github.com/postalserver/postal/compare/3.3.0...3.3.1) (2024-03-21)
### Bug Fixes
* **smtp-sender:** ensure relays without a host are excluded ([3a56ec8](https://github.com/postalserver/postal/commit/3a56ec8a74950e0162d98f1af5f58a67a82d6455))
* **smtp-sender:** fixes `SMTPSender.smtp_relays` ([b3264b9](https://github.com/postalserver/postal/commit/b3264b942776e254d3c351c94c435d172a514e18))
### Miscellaneous Chores
* **container:** add the branch name to the container ([bee5098](https://github.com/postalserver/postal/commit/bee509832edc151d97fe5bfc48c4973452873fc8))
* **github-actions:** don't generate commit- tags ([d65bbe0](https://github.com/postalserver/postal/commit/d65bbe0579037c5df962a18134bc007f5159d7e5))
* **github-actions:** don't run for dependabot or release-please PRs and fetch whole repo ([adaf2b0](https://github.com/postalserver/postal/commit/adaf2b07502e9ed91290873ad8465051c6fd814f))
* **github-actions:** include a version string on branch-*/latest images ([64bc7dc](https://github.com/postalserver/postal/commit/64bc7dcf7c0a8e006ab6eb6e8b4a52ad5e7e6528))
* **ui:** display branch in footer if present ([1823617](https://github.com/postalserver/postal/commit/18236171ebc398c157f2e61b15c7df9f91205284))
### Code Refactoring
* remove moonrope but maintain legacy API actions ([#2889](https://github.com/postalserver/postal/issues/2889)) ([4d9654d](https://github.com/postalserver/postal/commit/4d9654dac47d59c760be96388d0421de74d3e6ac))
## [3.3.0](https://github.com/postalserver/postal/compare/3.2.2...3.3.0) (2024-03-18)
### Features
* **prometheus:** add `postal_message_queue_latency` metric ([ee8d829](https://github.com/postalserver/postal/commit/ee8d829a854f91e476167869cafe35c2d37bb314))
* **worker:** allow number of threads to be configured ([7e2accc](https://github.com/postalserver/postal/commit/7e2acccd1ebd80750a3ebdb96cb5c36b5263cc24))
* **worker:** scale connection pool with worker threads ([ea542a0](https://github.com/postalserver/postal/commit/ea542a0694b3465b04fd3ebc439837df414deb1e))
### Bug Fixes
* **message-dequeuer:** ability to disable batching ([4fcb9e9](https://github.com/postalserver/postal/commit/4fcb9e9a2e34be5aa4bdf13f0529f40e564b72b4))
### Miscellaneous Chores
* **config-docs:** update docs for latest oidc defaults ([364eba6](https://github.com/postalserver/postal/commit/364eba6c5fce2f08a36489f42856ad5024a2062c))
* **config-docs:** update proxy protocol to mention v1 ([45dd8aa](https://github.com/postalserver/postal/commit/45dd8aaac56f15481cb7bf9081401cb28dc1e707))
## [3.2.2](https://github.com/postalserver/postal/compare/3.2.1...3.2.2) (2024-03-14)
### Bug Fixes
* don't use authentication on org & server deletion ([be45652](https://github.com/postalserver/postal/commit/be456523dd3aacb5c3eb45c9261da97ebffe603c))
* **smtp-server:** fixes proxy protocol ([9240612](https://github.com/postalserver/postal/commit/92406129cfcf1a06499a6f5aa18c73f1d6195793))
### Miscellaneous Chores
* allow config location message to be suppressed ([f760cdb](https://github.com/postalserver/postal/commit/f760cdb5a1d53e9c30ee495d129cbf12603a3cbd))
* hide further config messages ([1c67f72](https://github.com/postalserver/postal/commit/1c67f72209c93404d7024ce3d15f6f54f2d707c4))
* suppress config location on default-dkim-record ([aa76aae](https://github.com/postalserver/postal/commit/aa76aae2322af41af1bd60cfe1d69a11ac76324e))
### Tests
* add tests for the legacy API ([3d208d6](https://github.com/postalserver/postal/commit/3d208d632f4fc8a4adbfdb2bf4b377271eae6692))
## [3.2.1](https://github.com/postalserver/postal/compare/3.2.0...3.2.1) (2024-03-13)
### Bug Fixes
* fixes `postal default-dkim-record` ([58dddeb](https://github.com/postalserver/postal/commit/58dddebeb81dc6fab945d2b10a91588eddc471c2))
## [3.2.0](https://github.com/postalserver/postal/compare/3.1.1...3.2.0) (2024-03-13)
### Features
* add sha256 signatures to outgoing http requests ([#2874](https://github.com/postalserver/postal/issues/2874)) ([96d7365](https://github.com/postalserver/postal/commit/96d73653d7cb4dde1fbe74ccb3596147ef8cd9ed))
* automatically remove queued messages with stale locks ([#2872](https://github.com/postalserver/postal/issues/2872)) ([d84152e](https://github.com/postalserver/postal/commit/d84152eb5df6f963426d6ba8d02d39b3c146c8a5))
* openid connect support ([#2873](https://github.com/postalserver/postal/issues/2873)) ([5ed94f6](https://github.com/postalserver/postal/commit/5ed94f6f855735aa00544b2574dfb9e65d559a38))
### Bug Fixes
* **smtp-server:** add additional information to cram-md5 log entries ([9982bb8](https://github.com/postalserver/postal/commit/9982bb8c31ee4885d188666e2e8afdc218528df7))
### Styles
* **rubocop:** Style/TrailingCommaInArrayLiteral ([4e13577](https://github.com/postalserver/postal/commit/4e13577891dc827244abc6bf6d9ab4ee45860556))
### Miscellaneous Chores
* regenerate config docs ([5d8213a](https://github.com/postalserver/postal/commit/5d8213a98735f07fdf1700c7d01597654f41dbd0))
## [3.1.1](https://github.com/postalserver/postal/compare/3.1.0...3.1.1) (2024-03-08)
### Bug Fixes
* don't override paths in dockerfile ([9399e32](https://github.com/postalserver/postal/commit/9399e3223467cdacd010e70b58ad6093e128213d))
### Tests
* **smtp-sender:** add more tests for AUTH LOGIN ([22dcd49](https://github.com/postalserver/postal/commit/22dcd4901f188915cf4b3c758c6f2fc637a4e1e3))
## [3.1.0](https://github.com/postalserver/postal/compare/3.0.2...3.1.0) (2024-03-06)
### Features
* configurable trusted proxies for web requests ([3785c99](https://github.com/postalserver/postal/commit/3785c998513c634d225b489ccb43e926ce3f270a))
### Bug Fixes
* **message-dequeuer:** ensure SMTP endpoints are sent to SMTP sender appropriately ([e2d642c](https://github.com/postalserver/postal/commit/e2d642c0cbf443550886d90abc3a6edf3e4bc4fc)), closes [#2853](https://github.com/postalserver/postal/issues/2853)
* **smtp-server:** listen on all interfaces by default ([d1e5b68](https://github.com/postalserver/postal/commit/d1e5b68200ea4b9710cc8714afb3271bad1f4f66)), closes [#2852](https://github.com/postalserver/postal/issues/2852)
* **smtp-server:** remove ::ffff: from the start of ipv4 addresses ([0dc7359](https://github.com/postalserver/postal/commit/0dc7359431001c9ef1222913f8d1344093397596))
* **smtp-server:** reset ansi sequence after logging ([9bf6152](https://github.com/postalserver/postal/commit/9bf6152060ffb8b611b66818c1d1ac7c929b7ffe))
* **ui:** fixes typo on queue page ([33513a7](https://github.com/postalserver/postal/commit/33513a77c0df24d832ab7ed5237d68e2b1bde887))
* **web-server:** allow for trusted proxies not be set ([4e1deb2](https://github.com/postalserver/postal/commit/4e1deb2d2aeb61d9dddb3729916411c94e73c1c6))
### Styles
* **rubocop:** use _ when not using a variable in helm config exporter ([2c20ba6](https://github.com/postalserver/postal/commit/2c20ba65f64ccb0f8174e3f523dedb3806478782))
## [3.0.2](https://github.com/postalserver/postal/compare/3.0.1...3.0.2) (2024-03-05)
### Bug Fixes
* default to listening on all addresses when using legacy config ([48f6494](https://github.com/postalserver/postal/commit/48f6494240eb0374d5f865425b362e6f306b2653))
### Miscellaneous Chores
* removing arm64 support until we can get a reasonable build pipeline sorted ([e8e44f5](https://github.com/postalserver/postal/commit/e8e44f54b09426c8a04e466bc037851a0833d124))
## [3.0.1](https://github.com/postalserver/postal/compare/3.0.0...3.0.1) (2024-03-05)
### Bug Fixes
* fix issue with sending mail when smtp relays are configured ([6dd6e29](https://github.com/postalserver/postal/commit/6dd6e29929c70eaa8b9d3b33c184996b0b6abb82))
## [3.0.0](https://github.com/postalserver/postal/compare/2.3.2...3.0.0) (2024-03-04)
This version of Postal introduces a number of larger changes. Please be sure to follow the [upgrade guide](https://docs.postalserver.io/getting-started/upgrade-to-v3) when upgrading to Postal v3. Highlights include:
* Removal of RabbitMQ dependency
* Removal of `cron` and `requeuer` processes
* Improved logging
* Improved configuration
* Adds prometheus metric exporters for workers and SMTP servers
* Only accepted RFC-compliant end-of-DATA sequences (to avoid SMTP smuggling)
### Features
* add health server and prometheus metrics to worker ([a2eb70e](https://github.com/postalserver/postal/commit/a2eb70e))
* add option to delay starting processes until all migrations are run ([1c5ff5a](https://github.com/postalserver/postal/commit/1c5ff5a))
* add prometheus metrics to smtp server ([2e7b36c](https://github.com/postalserver/postal/commit/2e7b36c))
* add prometheus metrics to worker ([bea7450](https://github.com/postalserver/postal/commit/bea7450))
* more consistent logging ([044058d](https://github.com/postalserver/postal/commit/044058d))
* new background work process ([dc8e895](https://github.com/postalserver/postal/commit/dc8e895))
* new configuration system (and schema) (#2819) ([0163ac3](https://github.com/postalserver/postal/commit/0163ac3))
* only accept RFC-compliant End-of-DATA sequence ([0140dc4](https://github.com/postalserver/postal/commit/0140dc4))
* add "postal:update" task ([b35eea6](https://github.com/postalserver/postal/commit/b35eea6338f1888bfac2ed377d0a412680483e90))
* add helm env var config exporter ([8938988](https://github.com/postalserver/postal/commit/893898835dcd9684ae3401549389b173a3feb1fb))
* include list-unsubscribe-post header in dkim signatures ([e1bae60](https://github.com/postalserver/postal/commit/e1bae60768c4cf151d5a6a141985c78753dce02d)), closes [#2789](https://github.com/postalserver/postal/issues/2789) [#2788](https://github.com/postalserver/postal/issues/2788)
* support for additional SMTP client options ([0daa667](https://github.com/postalserver/postal/commit/0daa667b55fd9b948da643d37ec438e341809369))
### Bug Fixes
* fixes potential issue if machine hostname cannot be determined ([0fcf778](https://github.com/postalserver/postal/commit/0fcf778))
* raise an error if MX lookup times out during sending ([fadca88](https://github.com/postalserver/postal/commit/fadca88)), closes [#2833](https://github.com/postalserver/postal/issues/2833)
* set correct component in health server log lines ([a7a9a18](https://github.com/postalserver/postal/commit/a7a9a18))
* translate unicode domain names to Punycode for compatibility with DNS (#2823) ([be0df7b](https://github.com/postalserver/postal/commit/be0df7b))
* remove user email verification ([e05f0b3](https://github.com/postalserver/postal/commit/e05f0b3616da8b962b763c48a2139882fd88047a))
* unescape in URLs which are stored for tracking ([1da1182](https://github.com/postalserver/postal/commit/1da1182c23e9673d8f109d8ed29e80983cdccabf)), closes [#2568](https://github.com/postalserver/postal/issues/2568) [#907](https://github.com/postalserver/postal/issues/907) [#115](https://github.com/postalserver/postal/issues/115)
* update wording about tracking domain cnames ([0d3eccb](https://github.com/postalserver/postal/commit/0d3eccb368630b4fd21bd858a7829ec00c35f153)), closes [#2808](https://github.com/postalserver/postal/issues/2808)
### Documentation
* add message_db.encoding to config docs ([0c1f925](https://github.com/postalserver/postal/commit/0c1f925))
* add new repo readme welcome image ([afa1726](https://github.com/postalserver/postal/commit/afa1726))
* add quick contributing instructions ([8d21adc](https://github.com/postalserver/postal/commit/8d21adc))
* update SECURITY policy ([cfc1c9b](https://github.com/postalserver/postal/commit/cfc1c9b))
* update docs for how IP address allocation works ([07eb152](https://github.com/postalserver/postal/commit/07eb152)), closes [#2209](https://github.com/postalserver/postal/issues/2209)
### Miscellaneous Chores
* upgrade bundler to 2.5.6 ([1ae8ef6](https://github.com/postalserver/postal/commit/1ae8ef6))
* upgrade rails to 7.0 and other dependencies ([ecd09a2](https://github.com/postalserver/postal/commit/ecd09a2))
* upgrade ruby to 3.2.2 and nodejs to 20.x ([72715fe](https://github.com/postalserver/postal/commit/72715fe))
## [2.3.2](https://github.com/postalserver/postal/compare/2.3.1...2.3.2) (2024-03-01)
### Bug Fixes
* truncate output and details in deliveries to 250 characters ([694240d](https://github.com/postalserver/postal/commit/694240ddcdef1df9b32888de8fb743d2dee86462)), closes [#2831](https://github.com/postalserver/postal/issues/2831)
## [2.3.1](https://github.com/postalserver/postal/compare/2.3.0...2.3.1) (2024-02-23)
### Bug Fixes
* update raw headers after changing messages to during parsing ([2834e2c](https://github.com/postalserver/postal/commit/2834e2c37971db9b0b0498e38b382cf1f8ee26eb)), closes [#2816](https://github.com/postalserver/postal/issues/2816)
### Miscellaneous Chores
* **github-actions:** add 'docs' label to exclude from staleness checks ([57b72fb](https://github.com/postalserver/postal/commit/57b72fb4b7f7fc934cfa23906de65b8f6d6d1978))
* **github-actions:** add action to close stale issues and PRs ([d90a456](https://github.com/postalserver/postal/commit/d90a456dfa661d87e820160d2045c73c765564d2))
* **github-actions:** allow stale action to be run on demand ([559b08d](https://github.com/postalserver/postal/commit/559b08ddd31ecd904fd09c1e2822161b853166b9))
## [2.3.0](https://github.com/postalserver/postal/compare/2.2.2...2.3.0) (2024-02-13)
### Features
* privacy mode ([15f9671](https://github.com/postalserver/postal/commit/15f9671b667cf369255aaa27ee4257267990095c))
* remove strip_received_headers config option ([ed2e62b](https://github.com/postalserver/postal/commit/ed2e62b94fe76d7aeca0ede98f11a1c4f94c5996))
### Bug Fixes
* add ruby platform to gemfile ([1fceef7](https://github.com/postalserver/postal/commit/1fceef7cea76352fd6166fb3f1e8d0ff8591078e))
* explicitly disable TLS & starttls for unknown SSL modes ([42ab5b3](https://github.com/postalserver/postal/commit/42ab5b3a6b21992c89f8479137699dc9090f0ccc)), closes [#2564](https://github.com/postalserver/postal/issues/2564)
* fix bug with received header in SMTP server ([ba5bfbd](https://github.com/postalserver/postal/commit/ba5bfbd6a0af9ea33bedb2948822417bd1a3fbc5))
* retry mysql connections on message DB pool ([f9f7fb3](https://github.com/postalserver/postal/commit/f9f7fb30fee46b661b6dccde4362383ea591532b))
* use correct method for disconnecting smtp connections ([7c23994](https://github.com/postalserver/postal/commit/7c23994d243ec7d9a17ee053f8c3fa8de0a33097))
### Styles
* **rubocop:** disable complexity cops for now ([930cf39](https://github.com/postalserver/postal/commit/930cf39dba37401f90e293c938dee07daf3d9a31))
* **rubocop:** disable Style/SpecialGlobalVars ([be97f43](https://github.com/postalserver/postal/commit/be97f4330897f96085eb29ed7019b1a3e50af88e))
* **rubocop:** disable Style/StringConcatenation cop ([d508772](https://github.com/postalserver/postal/commit/d508772a40ef26e5c3a8304aa1f2b8c7985081bd))
* **rubocop:** Layout/* ([0e0aca0](https://github.com/postalserver/postal/commit/0e0aca06c90f6d2f4db1c4090a35c4537c76e13a))
* **rubocop:** Layout/EmptyLineAfterMagicComment ([0e4ed5c](https://github.com/postalserver/postal/commit/0e4ed5ca0393f9a56e1efa7ae377d2e4b876bfe1))
* **rubocop:** Layout/EmptyLines ([0cf35a8](https://github.com/postalserver/postal/commit/0cf35a83926d499a279775bcc32dd4ea79b7a8c9))
* **rubocop:** Layout/EmptyLinesAroundBlockBody ([cfd8d63](https://github.com/postalserver/postal/commit/cfd8d63321d1821aad7fa9d6b8462c3d551aca61))
* **rubocop:** Layout/LeadingCommentSpace ([59f299b](https://github.com/postalserver/postal/commit/59f299b704533488b74075beb8692397eb434aab))
* **rubocop:** Layout/LineLength ([e142d0d](https://github.com/postalserver/postal/commit/e142d0da5fbee19e6f9f1741ff9dee0a2d7dd169))
* **rubocop:** Layout/MultilineMethodCallBraceLayout ([a5d5ba5](https://github.com/postalserver/postal/commit/a5d5ba5326728413bb95456e92c854977d225a7f))
* **rubocop:** Lint/DuplicateBranch ([a1dc0f7](https://github.com/postalserver/postal/commit/a1dc0f77ac69937d7f30c9401608dfbe66987d45))
* **rubocop:** Lint/DuplicateMethods ([bab6346](https://github.com/postalserver/postal/commit/bab6346239e4f50bdd51101c45f3a0cd66f47096))
* **rubocop:** Lint/IneffectiveAccessModifier ([6ad56ee](https://github.com/postalserver/postal/commit/6ad56ee9c9e5bad19b065fcec3ada3280adbb1f4))
* **rubocop:** Lint/MissingSuper ([4674e63](https://github.com/postalserver/postal/commit/4674e63b5ff84307f5b772e870e88109af2daf52))
* **rubocop:** Lint/RedundantStringCoercion ([12a5ef3](https://github.com/postalserver/postal/commit/12a5ef3279bf6c1e5c38bf7e846de1d17bf98c09))
* **rubocop:** Lint/ShadowedException ([0966b44](https://github.com/postalserver/postal/commit/0966b44018bc1e2f131358635776fcc3b75ee8eb))
* **rubocop:** Lint/ShadowingOuterLocalVariable ([7119e86](https://github.com/postalserver/postal/commit/7119e8642dffeee7a27f90145073e45664ea58d3))
* **rubocop:** Lint/SuppressedException ([278ef08](https://github.com/postalserver/postal/commit/278ef0886ac53e6bed15793301dc69c95a37dbde))
* **rubocop:** Lint/UnderscorePrefixedVariableName ([ec7dcf4](https://github.com/postalserver/postal/commit/ec7dcf4f9a0bdb367a90f1a3b35336909ebc60d7))
* **rubocop:** Lint/UnusedBlockArgument ([ee94e4e](https://github.com/postalserver/postal/commit/ee94e4e1a013bbe8fbdd8ef94f15ed0fa20709ac))
* **rubocop:** Lint/UselessAssignment ([7590a46](https://github.com/postalserver/postal/commit/7590a462341bddd412e660db9546ba1909aea9d7))
* **rubocop:** Naming/FileName ([919a601](https://github.com/postalserver/postal/commit/919a60116c5d81ed787061ff4614da4f1e067d4e))
* **rubocop:** Naming/MemoizedInstanceVariableName ([9563f30](https://github.com/postalserver/postal/commit/9563f30c96fba12073e845319b8d79a542d88109))
* **rubocop:** relax method length and block nexting for now ([b0ac9ef](https://github.com/postalserver/postal/commit/b0ac9ef0b96ab78c2961f45b6e9f20f87a6f1d07))
* **rubocop:** remaining offences ([ec63666](https://github.com/postalserver/postal/commit/ec636661d5c4b9e8f48e6f263ffef834acb68b39))
* **rubocop:** Security/YAMLLoad ([389ea77](https://github.com/postalserver/postal/commit/389ea7705047bf8700836137514b2497af3c6c01))
* **rubocop:** Style/AndOr ([b9f3f31](https://github.com/postalserver/postal/commit/b9f3f313f8ec992917bad3a51f0481f89675e935))
* **rubocop:** Style/ClassAndModuleChildren ([e896f46](https://github.com/postalserver/postal/commit/e896f4689a8fc54979f0a6c2b7ce14746856bad6))
* **rubocop:** Style/For ([04a3483](https://github.com/postalserver/postal/commit/04a34831c74a3a44547f93100c35db650bc4eef6))
* **rubocop:** Style/FrozenStringLiteralComment ([6ab36c0](https://github.com/postalserver/postal/commit/6ab36c09c966eb9a8b8ada52155f74d2537977f2))
* **rubocop:** Style/GlobalStdStream ([75be690](https://github.com/postalserver/postal/commit/75be6907483ea25f828461eb790d3f6f46ca683b))
* **rubocop:** Style/GlobalVars ([157d114](https://github.com/postalserver/postal/commit/157d11457c520147807901b75b3ba22d29172f24))
* **rubocop:** Style/HashEachMethods ([c995027](https://github.com/postalserver/postal/commit/c995027ff53962ae49341372f75e2bf43ecde0d2))
* **rubocop:** Style/HashExcept ([83ac764](https://github.com/postalserver/postal/commit/83ac76451071f097e7197f77fc5ad16e9cf58593))
* **rubocop:** Style/IdenticalConditionalBranches ([6a58ecf](https://github.com/postalserver/postal/commit/6a58ecf605250b8fa891cb14fca0c0e0ce0d7eb9))
* **rubocop:** Style/MissingRespondToMissing ([ffcb707](https://github.com/postalserver/postal/commit/ffcb707247fe2a69905aa6e4dc668abeb9924611))
* **rubocop:** Style/MultilineBlockChain ([c6326a6](https://github.com/postalserver/postal/commit/c6326a6524e6d71d23bc2c256f3f9416c38b0846))
* **rubocop:** Style/MutableConstant ([129dffa](https://github.com/postalserver/postal/commit/129dffab9ed8726ca4066e7052adc699129de2d2))
* **rubocop:** Style/NumericPredicate ([c558f1c](https://github.com/postalserver/postal/commit/c558f1c69ce9498564161d8cef3fcb8213103498))
* **rubocop:** Style/PreferredHashMethods ([013b3ea](https://github.com/postalserver/postal/commit/013b3ea9315c14f24b08574d68e1688f33d78b8d))
* **rubocop:** Style/SafeNavigation ([00a02f2](https://github.com/postalserver/postal/commit/00a02f2655b6e3296ad0e7ea9b9872da936b56ed))
* **rubocop:** Style/SelectByRegexp ([9ce28a4](https://github.com/postalserver/postal/commit/9ce28a427fadf6fafd942e009792be4b5539d40d))
* **rubocop:** Style/StringLiterals ([b4cc812](https://github.com/postalserver/postal/commit/b4cc81264c85d5f0061e5b5121a30d7bdcabc189))
* **rubocop:** Style/WordArray ([bd85920](https://github.com/postalserver/postal/commit/bd8592056573d1c6933d753ed50bdf9a7466e761))
* **rubocop:** update rubocop rules ([6d4dea7](https://github.com/postalserver/postal/commit/6d4dea7f7f0145ff2b99cf2505bc70a3e5925256))
### Miscellaneous Chores
* annotate models ([6214892](https://github.com/postalserver/postal/commit/6214892710e21c2aa29a319d5809f7bdf0d50529))
* silence message DB migrations during provisioning ([c83601a](https://github.com/postalserver/postal/commit/c83601af69f35e338b1f7c10ef7994f74b96f8bf))
### Code Refactoring
* remove reloading on the smtp server ([c3c304e](https://github.com/postalserver/postal/commit/c3c304e98b3274433248792b6403acf63d7a513b))
### Tests
* add initial tests for Postal::SMTPServer::Client ([dece1d4](https://github.com/postalserver/postal/commit/dece1d487ac2fdce104700939a79a5579b60a0cb))
* FactoryBot.lint will lint all registered factories ([25d7d66](https://github.com/postalserver/postal/commit/25d7d66b4709fe5442d554097a6ef074aeb15f72))
* remove FACTORIES_EXCLUDED_FROM_LINT ([1cf665a](https://github.com/postalserver/postal/commit/1cf665a0cf61d1eae3d08bdadf6fccaab6413023))
## [2.2.2](https://github.com/postalserver/postal/compare/2.2.1...2.2.2) (2024-02-06)
### Bug Fixes
* adds new connection pool which will discard failed clients ([54306a9](https://github.com/postalserver/postal/commit/54306a974802c2e4d17e0980531e2d0dba08150a)), closes [#2780](https://github.com/postalserver/postal/issues/2780)
* re-add reconnect: true to database ([7bc5230](https://github.com/postalserver/postal/commit/7bc5230cbaae58fb6f8512d1d1b0e6a2eb989b56))
* upgrade nokogiri ([f05c2e4](https://github.com/postalserver/postal/commit/f05c2e4503688e59a5ef513a5a1064d0ebbb5813))
### Tests
* rename database spec file ([b9edcf5](https://github.com/postalserver/postal/commit/b9edcf5b7dda7f4976a9d3f90668bbdacea57350))
## [2.2.1](https://github.com/postalserver/postal/compare/2.2.0...2.2.1) (2024-02-03)
### Bug Fixes
* fixes issue starting application in production mode ([4528a14](https://github.com/postalserver/postal/commit/4528a14d273c141e5719f19c3b08c00364b47638))
### Code Refactoring
* remove Postal.database_url ([96ba4b8](https://github.com/postalserver/postal/commit/96ba4b8f309cfcd1d605e5c7fc05507b21c78c6f))
## [2.2.0](https://github.com/postalserver/postal/compare/2.1.6...2.2.0) (2024-02-01)
### Features
* load signing key path from POSTAL_SIGNING_KEY_PATH ([4a46f69](https://github.com/postalserver/postal/commit/4a46f690de3010f1ae4d6c17739530a4eae35c09))
* support for configuring postal with environment variables ([854aa5e](https://github.com/postalserver/postal/commit/854aa5ebc87de692b4691d48759aefd6fae9d133))
### Bug Fixes
* don't use indifferent access for job params ([2bad645](https://github.com/postalserver/postal/commit/2bad645d980ad4b712a3c863b5350e4ee2895071)), closes [#2477](https://github.com/postalserver/postal/issues/2477) [#2714](https://github.com/postalserver/postal/issues/2714) [#2476](https://github.com/postalserver/postal/issues/2476) [#2500](https://github.com/postalserver/postal/issues/2500)
* extract x-postal-tag before holding ([6b2bf90](https://github.com/postalserver/postal/commit/6b2bf9062d662ede14617c4995ffaacca023a3b1)), closes [#2684](https://github.com/postalserver/postal/issues/2684)
* fixes error messages in web ui ([71f51db](https://github.com/postalserver/postal/commit/71f51db3c2515addaf8b280667555427d64796be))
* ignore message DB migrations in autoloader ([3fb40e4](https://github.com/postalserver/postal/commit/3fb40e4e247893b314e42affa4604a7a71a52c59))
* move tracking middleware before host authorization ([49cceaa](https://github.com/postalserver/postal/commit/49cceaa6ca862965448041279fc439ecba163ff8)), closes [#2415](https://github.com/postalserver/postal/issues/2415)
* use utc timestamps when determining raw table names ([ce19bf7](https://github.com/postalserver/postal/commit/ce19bf7988d522bf46aabf68090751427e286ffc))
### Miscellaneous Chores
* add binstubs for bundle and rspec ([41f6cf4](https://github.com/postalserver/postal/commit/41f6cf4d909518526af55ecb3fcccfa8fb8e1da2))
* add script to send html emails to a local SMTP server ([8794a2f](https://github.com/postalserver/postal/commit/8794a2f44783658a075a6f3985079ae4743412b1))
### Code Refactoring
* remove explicit autoload ([0f9882f](https://github.com/postalserver/postal/commit/0f9882f13204124df630606b1b9e36787c9c4011))
* remove Postal::Job.perform method ([990b575](https://github.com/postalserver/postal/commit/990b575902c45bb1678cc95f53ef3166c4b7092e))
## [2.1.6](https://github.com/postalserver/postal/compare/2.1.5...2.1.6) (2024-01-30)
### Miscellaneous Chores
* **build:** fixes docker login action credentials ([8810856](https://github.com/postalserver/postal/commit/88108566f8ab33f1a4263a36a5c1ffc071645ac3))
* update release please to include more categories in changelog ([e156c21](https://github.com/postalserver/postal/commit/e156c21dee304de7d10c2958c493cce73c2d8fea))
## [2.1.5](https://github.com/postalserver/postal/compare/2.1.4...2.1.5) (2024-01-30)
### Bug Fixes
* duplicate string before modifying it to prevent frozen string errors ([f0a8aca](https://github.com/postalserver/postal/commit/f0a8aca6e10064fb16daefff9e22dcc20a831868))
* fixed typo (rfc number) ([2f62baa](https://github.com/postalserver/postal/commit/2f62baa238fc1102706ee4acf079b7a876b05283))
* fixes typo in on track domains page ([77bd77b](https://github.com/postalserver/postal/commit/77bd77b629fcbc44b8d27deb0d33a457b02309f2))
* mail view encoding issue [#2462](https://github.com/postalserver/postal/issues/2462) ([#2596](https://github.com/postalserver/postal/issues/2596)) ([59f4478](https://github.com/postalserver/postal/commit/59f44781973489817efb5b3435d95d25f44f90ce))
* match IPv4 mapped IPv6 addresses when searching for SMTP-IP credentials ([8b525d0](https://github.com/postalserver/postal/commit/8b525d0381a9e0113af808b9ec2eb47bf78ec60b))
## 2.1.4
### Bug Fixes
- Move RubyVer functionality to Utilities module ([5998bf](https://github.com/postalserver/postal/commit/5998bf376a274df19f29877e7f68ea75f298c9f9))
## 2.1.3
### Features
- Upgrade to Ruby 3.2.1 & Rails 6.1 ([957b78](https://github.com/postalserver/postal/commit/957b784658cda8c4c95cf1f2b65e05d99d23d427))
- Make resent-sender header optional ([c6fb8d](https://github.com/postalserver/postal/commit/c6fb8d223bdeaccdc9e8bdbd031fe3f325ac0677))
- Log CRAM-MD5 authentication failures ([9b1ed1](https://github.com/postalserver/postal/commit/9b1ed1e7e16a8f55a5bd7b7ce72195a08ca2968d))
- Always use multipart/alternative parts in generated emails ([d0db13](https://github.com/postalserver/postal/commit/d0db1345a2bf8f538b01b974e74391da6fffe2b1))
### Bug Fixes
- Use non-blocking function to negotiate TLS connections ([a7dd19](https://github.com/postalserver/postal/commit/a7dd19baac8300f4d8ee89d0050479e08fdf9176))
- Fix to newline conversion process ([9f4ef8](https://github.com/postalserver/postal/commit/9f4ef8f57a839c5529b4f00a36b832740386b4ed))
- Remove custom scrollbars ([b22f1b](https://github.com/postalserver/postal/commit/b22f1bdb2e2d66b096ca993d6a5f4f708274a4a2))
- Truncate 'output' field to avoid overflowing varchar(512) in database ([a188a1](https://github.com/postalserver/postal/commit/a188a161cbdcfd70158b09b53cef622842357c26))
- Fix link replacement in multipart messsages ([7ea00d](https://github.com/postalserver/postal/commit/7ea00dfa3bc3c7650cc2b134beacbff22101a913))
- Fix confusing error message when deleting IP pools ([cefc7d](https://github.com/postalserver/postal/commit/cefc7d17b82f610001859a8e323ee1dfde149ba5))
- Connect to correct IP rather than hostname suring SMTP delivery ([159509](https://github.com/postalserver/postal/commit/159509a3ed29ae33cba522b255904992922dcfdf))
- Change retry timings to avoid re-sending messages too early ([c8d27b](https://github.com/postalserver/postal/commit/c8d27b2963af122d6555abdf0742d2d2d6f11ce5))
## 2.1.2
### Features
- support for AMQPS for rabbitmq connections ([9f0697](https://github.com/postalserver/postal/commit/9f0697f194209f5fae5e451ba8fb888413fe37fa))
### Bug Fixes
- retry connections without SSL when SSL issue is encountered during smtp sending ([0dc682](https://github.com/postalserver/postal/commit/0dc6824a8f0315ea42b08f7e6812b821b62489c9))
## 2.1.1
### Features
- allow @ and % in webhook urls ([c60c69](https://github.com/postalserver/postal/commit/c60c69db1800775776da4c28c68001f230fe5888))
### Bug Fixes
- fixes broken styling on errors ([a0c87e](https://github.com/postalserver/postal/commit/a0c87e7bf16a19f06c13797e3329a4fed91370a1))
- use the Postal logger system for the rails log ([5b04fa](https://github.com/postalserver/postal/commit/5b04faca39c69757bd7d695b82984f8b4a41cac3))
## 2.1.0
### Features
- support for configuring the default spam threshold values for new servers ([724325](https://github.com/postalserver/postal/commit/724325a1b97d61ef1e134240e4f70aaad39dbf98))
- support for using rspamd for spam filtering ([a1277b](https://github.com/postalserver/postal/commit/a1277baba56ea6d6b4da4bba87b00cd3dbf0305e))
### Bug Fixes
- **dkim:** fixes timing race condition when signing emails ([232b60](https://github.com/postalserver/postal/commit/232b605f5bb8ab61156e1fb9860705fed017ed41))
- **docker:** fixes issue caused by changes to underlying ruby:2.6 image ([6570ff](https://github.com/postalserver/postal/commit/6570ff1f7797ff9a307dd96ed4ff37be14bf79ab))
## 2.0.0
### Features
- **ui:** add footer with links to docs and discussions ([1247da](https://github.com/postalserver/postal/commit/1247dae2e060a695a13a30ba072ca5e6dea45202))
### Bug Fixes
- **dkim:** ensure DKIM-Signature headers are appropriately wrapped ([476129](https://github.com/postalserver/postal/commit/476129cc1ba44e9014768d5ba7193587f78cb5d5))
- **docs:** update port numbers to specify the actual port number the SMTP server is listening on ([4404b3](https://github.com/postalserver/postal/commit/4404b3e02c1722808157c3f590310ead9e28641d))
- **logging:** fix spelling of graylog ([2a11e0](https://github.com/postalserver/postal/commit/2a11e0c0a5b7c7f630af28cf4af5511d9bce6dda))
## 2.0.0-beta.1
### Features
- **config:** support for loading a postal.local.yml config file from the config root if it exists ([8e3294](https://github.com/postalhq/postal/commit/8e3294ba1af4b797d36bd1ca9226190ed80f65cc))
- **smtp_server:** allow bind address to be configured ([4a410c](https://github.com/postalhq/postal/commit/4a410c8c9f6fa1ef993a68c37afeaf31230585f7))
- add priorities to IP address assignment ([21a8d8](https://github.com/postalhq/postal/commit/21a8d890459958375d4a49a5b7f31f4900a9e8b1))
### Bug Fixes
- **dkim:** fixes bug with signing dkim bodies ([189dfa](https://github.com/postalhq/postal/commit/189dfa509b4750f1e4cc6f43f6565edd3a35139c))
- **smtp_server:** attempt to redact plain-text passwords from log output ([fcb636](https://github.com/postalhq/postal/commit/fcb63616e1ce578d7d4fd1c96ddc4ee0f7a71534))
- **smtp_server:** fixes issue with malformed rcpt to ([e0ba05](https://github.com/postalhq/postal/commit/e0ba05acb11108d98a460ae3fac653ceefb5f672))
- **smtp_server:** refactor mx lookups to randomly order mx records with the same priority ([bc2239](https://github.com/postalhq/postal/commit/bc22394fdd4f26dddd576840b49d7c25802cda7d))
- **smtp_server:** updated line split logic, normalize all linebreaks to \r\n ([e8ba9e](https://github.com/postalhq/postal/commit/e8ba9ee4276e81af84ecb6ff6f0c024ef99f6ddc))
- add resolv 0.2.1 ([eef1a3](https://github.com/postalhq/postal/commit/eef1a365a28e133750c4d5a4ac0eeeed223e303d))
- always obey POSTAL_CONFIG_ROOT ([1d22ca](https://github.com/postalhq/postal/commit/1d22ca0f85b58b04aedde9071d9fc5ecd44af4de))
- fix issue with determining if an SMTP connection is encrypted or not ([73870d](https://github.com/postalhq/postal/commit/73870d6a92400fc8ec1493016817dfac074ffd06))
- remove a few leftover fast server artifacts ([5cd06e](https://github.com/postalhq/postal/commit/5cd06e126b6caac502245754b360194365152415))
- replace Fixnum with Integer ([52a23f](https://github.com/postalhq/postal/commit/52a23fa86f94c14dfc7edccbf414dda34c46bc12))
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Postal
This doc explains how to go about running Postal in development to allow you to make contributions to the project.
## Dependencies
You will need a MySQL database server to get started. Postal needs to be able to make databases within that server whenever new mail servers are created so the permissions that you use should be suitable for that.
You'll also need Ruby. Postal currently uses Ruby 3.2.2. Install that using whichever version manager takes your fancy - rbenv, asdf, rvm etc.
## Clone
You'll need to clone the repository
```
git clone git@github.com:postalserver/postal
```
Once cloned, you can install the Ruby dependencies using bundler.
```
bundle install
```
## Configuration
Configuration is handled using a config file. This lives in `config/postal/postal.yml`. An example configuration file is provided in `config/examples/development.yml`. This example is for development use only and not an example for production use.
You'll also need a key for signing. You can generate one of these like this:
```
openssl genrsa -out config/postal/signing.key 2048
```
If you're running the tests (and you probably should be), you'll find an example file for test configuration in `config/examples/test.yml`. This should be placed in `config/postal/postal.test.yml` with the appropriate values.
If you prefer, you can configure Postal using environment variables. These should be placed in `.env` or `.env.test` as apprpriate.
## Running
The neatest way to run postal is to ensure that `./bin` is your `$PATH` and then use one of the following commands.
* `bin/dev` - will run all components of the application using Foreman
* `bin/postal` - will run the Postal binary providing access to running individual components or other tools.
## Database initialization
Use the commands below to initialize your database and make your first user.
```
postal initialize
postal make-user
```
================================================
FILE: Dockerfile
================================================
FROM ruby:3.4.6-slim-bookworm AS base
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update \
&& apt-get install --no-install-recommends -y curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN (curl -sL https://deb.nodesource.com/setup_20.x | bash -)
# Install main dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
netcat-openbsd \
libmariadb-dev \
libcap2-bin \
nano \
libyaml-dev \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/ruby
# Configure 'postal' to work everywhere (when the binary exists
# later in this process)
ENV PATH="/opt/postal/app/bin:${PATH}"
# Setup an application
RUN useradd -r -d /opt/postal -m -s /bin/bash -u 999 postal
USER postal
RUN mkdir -p /opt/postal/app /opt/postal/config
WORKDIR /opt/postal/app
# Install bundler
RUN gem install bundler -v 2.7.2 --no-doc
# Install the latest and active gem dependencies and re-run
# the appropriate commands to handle installs.
COPY --chown=postal Gemfile Gemfile.lock ./
RUN bundle install
# Copy the application (and set permissions)
COPY ./docker/wait-for.sh /docker-entrypoint.sh
COPY --chown=postal . .
# Export the version
ARG VERSION
ARG BRANCH
RUN if [ "$VERSION" != "" ]; then echo $VERSION > VERSION; fi \
&& if [ "$BRANCH" != "" ]; then echo $BRANCH > BRANCH; fi
# Set paths for when running in a container
ENV POSTAL_CONFIG_FILE_PATH=/config/postal.yml
# Set the CMD
ENTRYPOINT [ "/docker-entrypoint.sh" ]
CMD ["postal"]
# ci target - use --target=ci to skip asset compilation
FROM base AS ci
# full target - default if no --target option is given
FROM base AS full
RUN RAILS_GROUPS=assets bundle exec rake assets:precompile
RUN touch /opt/postal/app/public/assets/.prebuilt
================================================
FILE: Gemfile
================================================
# frozen_string_literal: true
source "https://rubygems.org"
gem "abbrev"
gem "authie"
gem "autoprefixer-rails"
gem "bcrypt"
gem "chronic"
gem "domain_name"
gem "dotenv"
gem "dynamic_form"
gem "execjs", "~> 2.7", "< 2.8"
gem "gelf"
gem "haml"
gem "hashie"
gem "highline", require: false
gem "jwt"
gem "kaminari"
gem "klogger-logger"
gem "konfig-config", "~> 3.0"
gem "logger"
gem "mail"
gem "mutex_m"
gem "mysql2"
gem "nifty-utils"
gem "nilify_blanks"
gem "nio4r"
gem "ostruct"
gem "prometheus-client"
gem "puma"
gem "rackup"
gem "rails", "= 7.1.5.2"
gem "resolv"
gem "secure_headers"
gem "sentry-rails"
gem "turbolinks", "~> 5"
gem "webrick"
group :oidc do
# These are gems which are needed for OpenID connect. They are only required by the application
# when OIDC is enabled in the Postal configuration.
gem "omniauth_openid_connect"
gem "omniauth-rails_csrf_protection"
end
group :development, :assets do
gem "coffee-rails", "~> 5.0"
gem "jquery-rails"
gem "sass-rails"
gem "uglifier", ">= 1.3.0"
end
group :development do
gem "annotate"
gem "rubocop"
gem "rubocop-rails"
end
group :test do
gem "database_cleaner-active_record"
gem "factory_bot_rails"
gem "rspec"
gem "rspec-rails"
gem "shoulda-matchers"
gem "timecop"
gem "webmock"
end
================================================
FILE: MIT-LICENCE
================================================
Copyright 2017-2024 Krystal Hosting Ltd
Copyright 2024 Adam Cooke
Copyright 2024 Charlie Smurthwaite
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: Procfile.dev
================================================
web: unset PORT; bundle exec puma -C config/puma.rb
worker: bundle exec ruby script/worker.rb
smtp: unset PORT; bundle exec ruby script/smtp_server.rb
================================================
FILE: README.md
================================================

**Postal** is a complete and fully featured mail server for use by websites & web servers. Think Sendgrid, Mailgun or Postmark but open source and ready for you to run on your own servers.
* [Documentation](https://docs.postalserver.io)
* [Installation Instructions](https://docs.postalserver.io/getting-started)
* [FAQs](https://docs.postalserver.io/welcome/faqs) & [Features](https://docs.postalserver.io/welcome/feature-list)
* [Discussions](https://github.com/postalserver/postal/discussions) - ask for help or request a feature
* [Join us on Discord](https://discord.postalserver.io)
================================================
FILE: Rakefile
================================================
# frozen_string_literal: true
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Supported Versions
We only support updates to the 3.x versions of Postal.
| Version | Supported |
| ------- | ------------------ |
| 3.x.x | :white_check_mark: |
| < 3.0 | :x: |
## Reporting a Vulnerability
If you discover a vulnerability in Postal, please do not post an issue on GitHub. Instead you should send an
e-mail to security@postalserver.io with details. We will get back to you directly.
================================================
FILE: app/assets/config/manifest.js
================================================
//= link_tree ../images
//= link_directory ../javascripts .js
//= link_directory ../stylesheets .css
//= link application/application.css
//= link application/application.js
================================================
FILE: app/assets/images/.keep
================================================
================================================
FILE: app/assets/javascripts/application/application.coffee
================================================
#= require jquery
#= require jquery_ujs
#= require turbolinks
#= require_tree ./vendor/.
#= require_self
#= require_tree .
$ ->
isFirefox = -> !!navigator.userAgent.match(/firefox/i)
$('html').addClass('browser-firefox') if isFirefox()
$(document).on 'turbolinks:load', ->
$('.js-multibox').multibox({inputCount: 6, classNames: {container: "multibox", input: 'input input--text multibox__input'}})
$(document).on 'keyup', (event)->
return if $(event.target).is('input, select, textarea')
if event.keyCode == 83
$('.js-focus-on-s').focus()
event.preventDefault()
if event.keyCode == 70
$('.js-focus-on-f').focus()
event.preventDefault()
$(document).on 'click', 'html.main .flashMessage', ->
$(this).hide 'fast', ->
$(this).remove()
$(document).on 'click', '.js-toggle-helpbox', ->
helpBox = $('.js-helpbox')
if helpBox.hasClass('is-hidden')
helpBox.removeClass('is-hidden')
else
helpBox.addClass('is-hidden')
return false
$(document).on 'input', 'input[type=range]', ->
value = $(this).val()
updateAttr = $(this).attr('data-update')
if updateAttr && updateAttr.length
$("." + $(this).attr('data-update')).text(parseFloat(value, 10).toFixed(1))
$(document).on 'change', '.js-checkbox-list-toggle', ->
$this = $(this)
value = $this.val()
$list = $this.parent().find('.checkboxList')
if value == 'false' then $list.show() else $list.hide()
$(document).on 'click', '.js-toggle', ->
$link = $(this)
element = $link.attr('data-element')
$(element, $link.parent()).toggle()
false
toggleCredentialInputs = (type)->
$('[data-credential-key-type]').hide()
$('[data-credential-key-type] input').attr('disabled', true)
if type == 'SMTP-IP'
$('[data-credential-key-type=smtp-ip]').show()
$('[data-credential-key-type=smtp-ip] input').attr('disabled', false)
else
$('[data-credential-key-type=all]').show()
$(document).on 'change', 'select#credential_type', ->
value = $(this).val()
toggleCredentialInputs(value)
$(document).on 'turbolinks:load', ->
credentialTypeInput = $('select#credential_type')
if credentialTypeInput.length
toggleCredentialInputs(credentialTypeInput.val())
================================================
FILE: app/assets/javascripts/application/elements/ajax.coffee
================================================
onStart = (event) ->
$('.flashMessage').remove()
$('input, select, textarea').blur()
$target = $(event.target)
if $target.is('form')
$('.js-form-submit', $target).addClass('is-spinning')
if $target.hasClass('button')
$($target).addClass('is-spinning')
onComplete = (event, xhr)->
$target = $(event.target)
if xhr.responseJSON
data = xhr.responseJSON
if data.redirect_to
Turbolinks.clearCache()
Turbolinks.visit(data.redirect_to, {"action":"replace"})
console.log "Redirected to #{data.redirect_to}"
if data.alert
unSpin($target)
alert(data.alert)
if data.form_errors
if $target.is('form')
unSpin($target)
handleErrors($target, data.form_errors)
if data.flash
unSpin($target)
$('body .flashMessage').remove()
for key, value of data.flash
$message = $("#{value}
")
$('body').prepend($message)
if data.region_html
unSpin($target)
$('.js-ajax-region').replaceWith(data.region_html)
$('[autofocus]', '.js-ajax-region').focus()
else
console.log "Unsupported return."
unSpin = ($target)->
$target.removeClass('is-spinning')
$('.js-form-submit', $target).removeClass('is-spinning')
handleErrors = (form, errors)->
html = $("")
list = $('ul', html)
$.each errors, ->
list.append("
#{this} ")
$('.formErrors', form).remove()
form.prepend($(html))
console.log errors
$ ->
$.ajaxSettings.dataType = 'json'
$(document)
.on 'ajax:before', onStart
.on 'ajax:complete', onComplete
================================================
FILE: app/assets/javascripts/application/elements/mail_graph.coffee
================================================
$(document).on 'turbolinks:load', ->
mailGraph = $('.mailGraph')
if mailGraph.length
data = JSON.parse(mailGraph.attr('data-data'))
incomingMail = []
outgoingMail = []
for d in data
incomingMail.push(d.incoming)
outgoingMail.push(d.outgoing)
data =
series: [outgoingMail, incomingMail]
options =
fullWidth: true
axisY:
offset:40
axisX:
showGrid: false
offset: 0
showLabel: true
height: '230px'
showArea: true
high: if incomingMail? && incomingMail.length then undefined else 1000
chartPadding:
top:0
right:0
bottom:0
left:0
new Chartist.Line '.mailGraph__graph', data, options
================================================
FILE: app/assets/javascripts/application/elements/remembering.coffee
================================================
$ ->
$(document).on 'click', '.js-remember a', ->
$parent = $(this).parents('.js-remember')
value = $(this).attr('data-remember')
$parent.remove()
if value == 'yes'
$.post('/persist')
false
================================================
FILE: app/assets/javascripts/application/elements/searchable.coffee
================================================
ENTER = 13
DOWN_ARROW = 40
UP_ARROW = 38
filterList = ($container, query) ->
$items = getItems($container)
index = $container.data('searchifyIndex')
re = new RegExp(query, 'g')
$matches = $items.filter (i, item) ->
value = $(item).data('value')
re.test(value)
$items.addClass('is-hidden').filter($matches).removeClass('is-hidden')
toggleState($container, $matches.length > 0)
if index?
index = 0
$container.data('searchifyIndex', index)
highlightItem($container, $matches, index)
getContainer = ($el) ->
$el.closest('.js-searchable')
getEmpty = ($container) ->
$('.js-searchable__empty', $container)
getList = ($container) ->
$('.js-searchable__list', $container)
getItems = ($container) ->
$('.js-searchable__item', $container)
highlightItem = ($container, $scope, index) ->
$items = getItems($container)
$items.removeClass('is-highlighted')
$scope.eq(index).addClass('is-highlighted') if index? && $scope.length
highlightNext = ($container) ->
$matches = getMatches($container)
index = $container.data('searchifyIndex')
return unless $matches.length
if index?
return if index == $matches.length - 1
newIndex = index + 1
else
newIndex = 0
$container.data('searchifyIndex', newIndex)
highlightItem($container, $matches, newIndex)
highlightPrev = ($container) ->
$matches = getMatches($container)
index = $container.data('searchifyIndex')
return unless $matches.length
if index?
return if index == 0
newIndex = index - 1
else
newIndex = 0
$container.data('searchifyIndex', newIndex)
highlightItem($container, $matches, newIndex)
getMatches = ($container) ->
$items = getItems($container)
$items.filter(':not(.is-hidden)')
searchify = (str) ->
str.toLowerCase().replace(/\W/g, '')
selectHighlighted = ($container) ->
index = $container.data('searchifyIndex')
$matches = getMatches($container)
return unless index? && $matches.length
url = $matches.eq(index).data('url')
Turbolinks.visit(url)
showAll = ($container) ->
$items = getItems($container)
index = $container.data('searchifyIndex')
$items.removeClass('is-hidden')
toggleState($container, true)
if index?
index = 0
$container.data('searchifyIndex', index)
highlightItem($container, $items, index)
toggleState = ($container, predicate) ->
$empty = getEmpty($container)
$list = getList($container)
$empty.toggleClass('is-hidden', predicate)
$list.toggleClass('is-hidden', !predicate)
# Event Handlers
handleInput = (event) ->
$input = $(event.target)
$container = getContainer($input)
query = searchify($input.val())
if query.length then filterList($container, query) else showAll($container)
handleKeydown = (event) ->
$container = getContainer($(event.target))
keyCode = event.keyCode
if keyCode == DOWN_ARROW
event.preventDefault()
highlightNext($container)
else if keyCode == ENTER
event.preventDefault()
selectHighlighted($container)
else if keyCode == UP_ARROW
event.preventDefault()
highlightPrev($container)
$ ->
$(document)
.on('input', '.js-searchable__input', handleInput)
.on('keydown', '.js-searchable__input', handleKeydown)
================================================
FILE: app/assets/javascripts/application/vendor/chartist.js
================================================
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module unless amdModuleId is set
define([], function () {
return (root['Chartist'] = factory());
});
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
root['Chartist'] = factory();
}
}(this, function () {
/* Chartist.js 0.9.8
* Copyright © 2016 Gion Kunz
* Free to use under either the WTFPL license or the MIT license.
* https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-WTFPL
* https://raw.githubusercontent.com/gionkunz/chartist-js/master/LICENSE-MIT
*/
/**
* The core module of Chartist that is mainly providing static functions and higher level functions for chart modules.
*
* @module Chartist.Core
*/
var Chartist = {
version: '0.9.8'
};
(function (window, document, Chartist) {
'use strict';
/**
* This object contains all namespaces used within Chartist.
*
* @memberof Chartist.Core
* @type {{svg: string, xmlns: string, xhtml: string, xlink: string, ct: string}}
*/
Chartist.namespaces = {
svg: 'http://www.w3.org/2000/svg',
xmlns: 'http://www.w3.org/2000/xmlns/',
xhtml: 'http://www.w3.org/1999/xhtml',
xlink: 'http://www.w3.org/1999/xlink',
ct: 'http://gionkunz.github.com/chartist-js/ct'
};
/**
* Helps to simplify functional style code
*
* @memberof Chartist.Core
* @param {*} n This exact value will be returned by the noop function
* @return {*} The same value that was provided to the n parameter
*/
Chartist.noop = function (n) {
return n;
};
/**
* Generates a-z from a number 0 to 26
*
* @memberof Chartist.Core
* @param {Number} n A number from 0 to 26 that will result in a letter a-z
* @return {String} A character from a-z based on the input number n
*/
Chartist.alphaNumerate = function (n) {
// Limit to a-z
return String.fromCharCode(97 + n % 26);
};
/**
* Simple recursive object extend
*
* @memberof Chartist.Core
* @param {Object} target Target object where the source will be merged into
* @param {Object...} sources This object (objects) will be merged into target and then target is returned
* @return {Object} An object that has the same reference as target but is extended and merged with the properties of source
*/
Chartist.extend = function (target) {
target = target || {};
var sources = Array.prototype.slice.call(arguments, 1);
sources.forEach(function(source) {
for (var prop in source) {
if (typeof source[prop] === 'object' && source[prop] !== null && !(source[prop] instanceof Array)) {
target[prop] = Chartist.extend({}, target[prop], source[prop]);
} else {
target[prop] = source[prop];
}
}
});
return target;
};
/**
* Replaces all occurrences of subStr in str with newSubStr and returns a new string.
*
* @memberof Chartist.Core
* @param {String} str
* @param {String} subStr
* @param {String} newSubStr
* @return {String}
*/
Chartist.replaceAll = function(str, subStr, newSubStr) {
return str.replace(new RegExp(subStr, 'g'), newSubStr);
};
/**
* Converts a number to a string with a unit. If a string is passed then this will be returned unmodified.
*
* @memberof Chartist.Core
* @param {Number} value
* @param {String} unit
* @return {String} Returns the passed number value with unit.
*/
Chartist.ensureUnit = function(value, unit) {
if(typeof value === 'number') {
value = value + unit;
}
return value;
};
/**
* Converts a number or string to a quantity object.
*
* @memberof Chartist.Core
* @param {String|Number} input
* @return {Object} Returns an object containing the value as number and the unit as string.
*/
Chartist.quantity = function(input) {
if (typeof input === 'string') {
var match = (/^(\d+)\s*(.*)$/g).exec(input);
return {
value : +match[1],
unit: match[2] || undefined
};
}
return { value: input };
};
/**
* This is a wrapper around document.querySelector that will return the query if it's already of type Node
*
* @memberof Chartist.Core
* @param {String|Node} query The query to use for selecting a Node or a DOM node that will be returned directly
* @return {Node}
*/
Chartist.querySelector = function(query) {
return query instanceof Node ? query : document.querySelector(query);
};
/**
* Functional style helper to produce array with given length initialized with undefined values
*
* @memberof Chartist.Core
* @param length
* @return {Array}
*/
Chartist.times = function(length) {
return Array.apply(null, new Array(length));
};
/**
* Sum helper to be used in reduce functions
*
* @memberof Chartist.Core
* @param previous
* @param current
* @return {*}
*/
Chartist.sum = function(previous, current) {
return previous + (current ? current : 0);
};
/**
* Multiply helper to be used in `Array.map` for multiplying each value of an array with a factor.
*
* @memberof Chartist.Core
* @param {Number} factor
* @returns {Function} Function that can be used in `Array.map` to multiply each value in an array
*/
Chartist.mapMultiply = function(factor) {
return function(num) {
return num * factor;
};
};
/**
* Add helper to be used in `Array.map` for adding a addend to each value of an array.
*
* @memberof Chartist.Core
* @param {Number} addend
* @returns {Function} Function that can be used in `Array.map` to add a addend to each value in an array
*/
Chartist.mapAdd = function(addend) {
return function(num) {
return num + addend;
};
};
/**
* Map for multi dimensional arrays where their nested arrays will be mapped in serial. The output array will have the length of the largest nested array. The callback function is called with variable arguments where each argument is the nested array value (or undefined if there are no more values).
*
* @memberof Chartist.Core
* @param arr
* @param cb
* @return {Array}
*/
Chartist.serialMap = function(arr, cb) {
var result = [],
length = Math.max.apply(null, arr.map(function(e) {
return e.length;
}));
Chartist.times(length).forEach(function(e, index) {
var args = arr.map(function(e) {
return e[index];
});
result[index] = cb.apply(null, args);
});
return result;
};
/**
* This helper function can be used to round values with certain precision level after decimal. This is used to prevent rounding errors near float point precision limit.
*
* @memberof Chartist.Core
* @param {Number} value The value that should be rounded with precision
* @param {Number} [digits] The number of digits after decimal used to do the rounding
* @returns {number} Rounded value
*/
Chartist.roundWithPrecision = function(value, digits) {
var precision = Math.pow(10, digits || Chartist.precision);
return Math.round(value * precision) / precision;
};
/**
* Precision level used internally in Chartist for rounding. If you require more decimal places you can increase this number.
*
* @memberof Chartist.Core
* @type {number}
*/
Chartist.precision = 8;
/**
* A map with characters to escape for strings to be safely used as attribute values.
*
* @memberof Chartist.Core
* @type {Object}
*/
Chartist.escapingMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
'\'': '''
};
/**
* This function serializes arbitrary data to a string. In case of data that can't be easily converted to a string, this function will create a wrapper object and serialize the data using JSON.stringify. The outcoming string will always be escaped using Chartist.escapingMap.
* If called with null or undefined the function will return immediately with null or undefined.
*
* @memberof Chartist.Core
* @param {Number|String|Object} data
* @return {String}
*/
Chartist.serialize = function(data) {
if(data === null || data === undefined) {
return data;
} else if(typeof data === 'number') {
data = ''+data;
} else if(typeof data === 'object') {
data = JSON.stringify({data: data});
}
return Object.keys(Chartist.escapingMap).reduce(function(result, key) {
return Chartist.replaceAll(result, key, Chartist.escapingMap[key]);
}, data);
};
/**
* This function de-serializes a string previously serialized with Chartist.serialize. The string will always be unescaped using Chartist.escapingMap before it's returned. Based on the input value the return type can be Number, String or Object. JSON.parse is used with try / catch to see if the unescaped string can be parsed into an Object and this Object will be returned on success.
*
* @memberof Chartist.Core
* @param {String} data
* @return {String|Number|Object}
*/
Chartist.deserialize = function(data) {
if(typeof data !== 'string') {
return data;
}
data = Object.keys(Chartist.escapingMap).reduce(function(result, key) {
return Chartist.replaceAll(result, Chartist.escapingMap[key], key);
}, data);
try {
data = JSON.parse(data);
data = data.data !== undefined ? data.data : data;
} catch(e) {}
return data;
};
/**
* Create or reinitialize the SVG element for the chart
*
* @memberof Chartist.Core
* @param {Node} container The containing DOM Node object that will be used to plant the SVG element
* @param {String} width Set the width of the SVG element. Default is 100%
* @param {String} height Set the height of the SVG element. Default is 100%
* @param {String} className Specify a class to be added to the SVG element
* @return {Object} The created/reinitialized SVG element
*/
Chartist.createSvg = function (container, width, height, className) {
var svg;
width = width || '100%';
height = height || '100%';
// Check if there is a previous SVG element in the container that contains the Chartist XML namespace and remove it
// Since the DOM API does not support namespaces we need to manually search the returned list http://www.w3.org/TR/selectors-api/
Array.prototype.slice.call(container.querySelectorAll('svg')).filter(function filterChartistSvgObjects(svg) {
return svg.getAttributeNS(Chartist.namespaces.xmlns, 'ct');
}).forEach(function removePreviousElement(svg) {
container.removeChild(svg);
});
// Create svg object with width and height or use 100% as default
svg = new Chartist.Svg('svg').attr({
width: width,
height: height
}).addClass(className).attr({
style: 'width: ' + width + '; height: ' + height + ';'
});
// Add the DOM node to our container
container.appendChild(svg._node);
return svg;
};
/**
* Ensures that the data object passed as second argument to the charts is present and correctly initialized.
*
* @param {Object} data The data object that is passed as second argument to the charts
* @return {Object} The normalized data object
*/
Chartist.normalizeData = function(data) {
// Ensure data is present otherwise enforce
data = data || {series: [], labels: []};
data.series = data.series || [];
data.labels = data.labels || [];
// Check if we should generate some labels based on existing series data
if (data.series.length > 0 && data.labels.length === 0) {
var normalized = Chartist.getDataArray(data),
labelCount;
// If all elements of the normalized data array are arrays we're dealing with
// data from Bar or Line charts and we need to find the largest series if they are un-even
if (normalized.every(function(value) {
return value instanceof Array;
})) {
// Getting the series with the the most elements
labelCount = Math.max.apply(null, normalized.map(function(series) {
return series.length;
}));
} else {
// We're dealing with Pie data so we just take the normalized array length
labelCount = normalized.length;
}
// Setting labels to an array with emptry strings using our labelCount estimated above
data.labels = Chartist.times(labelCount).map(function() {
return '';
});
}
return data;
};
/**
* Reverses the series, labels and series data arrays.
*
* @memberof Chartist.Core
* @param data
*/
Chartist.reverseData = function(data) {
data.labels.reverse();
data.series.reverse();
for (var i = 0; i < data.series.length; i++) {
if(typeof(data.series[i]) === 'object' && data.series[i].data !== undefined) {
data.series[i].data.reverse();
} else if(data.series[i] instanceof Array) {
data.series[i].reverse();
}
}
};
/**
* Convert data series into plain array
*
* @memberof Chartist.Core
* @param {Object} data The series object that contains the data to be visualized in the chart
* @param {Boolean} reverse If true the whole data is reversed by the getDataArray call. This will modify the data object passed as first parameter. The labels as well as the series order is reversed. The whole series data arrays are reversed too.
* @param {Boolean} multi Create a multi dimensional array from a series data array where a value object with `x` and `y` values will be created.
* @return {Array} A plain array that contains the data to be visualized in the chart
*/
Chartist.getDataArray = function (data, reverse, multi) {
// If the data should be reversed but isn't we need to reverse it
// If it's reversed but it shouldn't we need to reverse it back
// That's required to handle data updates correctly and to reflect the responsive configurations
if(reverse && !data.reversed || !reverse && data.reversed) {
Chartist.reverseData(data);
data.reversed = !data.reversed;
}
// Recursively walks through nested arrays and convert string values to numbers and objects with value properties
// to values. Check the tests in data core -> data normalization for a detailed specification of expected values
function recursiveConvert(value) {
if(Chartist.isFalseyButZero(value)) {
// This is a hole in data and we should return undefined
return undefined;
} else if((value.data || value) instanceof Array) {
return (value.data || value).map(recursiveConvert);
} else if(value.hasOwnProperty('value')) {
return recursiveConvert(value.value);
} else {
if(multi) {
var multiValue = {};
// Single series value arrays are assumed to specify the Y-Axis value
// For example: [1, 2] => [{x: undefined, y: 1}, {x: undefined, y: 2}]
// If multi is a string then it's assumed that it specified which dimension should be filled as default
if(typeof multi === 'string') {
multiValue[multi] = Chartist.getNumberOrUndefined(value);
} else {
multiValue.y = Chartist.getNumberOrUndefined(value);
}
multiValue.x = value.hasOwnProperty('x') ? Chartist.getNumberOrUndefined(value.x) : multiValue.x;
multiValue.y = value.hasOwnProperty('y') ? Chartist.getNumberOrUndefined(value.y) : multiValue.y;
return multiValue;
} else {
return Chartist.getNumberOrUndefined(value);
}
}
}
return data.series.map(recursiveConvert);
};
/**
* Converts a number into a padding object.
*
* @memberof Chartist.Core
* @param {Object|Number} padding
* @param {Number} [fallback] This value is used to fill missing values if a incomplete padding object was passed
* @returns {Object} Returns a padding object containing top, right, bottom, left properties filled with the padding number passed in as argument. If the argument is something else than a number (presumably already a correct padding object) then this argument is directly returned.
*/
Chartist.normalizePadding = function(padding, fallback) {
fallback = fallback || 0;
return typeof padding === 'number' ? {
top: padding,
right: padding,
bottom: padding,
left: padding
} : {
top: typeof padding.top === 'number' ? padding.top : fallback,
right: typeof padding.right === 'number' ? padding.right : fallback,
bottom: typeof padding.bottom === 'number' ? padding.bottom : fallback,
left: typeof padding.left === 'number' ? padding.left : fallback
};
};
Chartist.getMetaData = function(series, index) {
var value = series.data ? series.data[index] : series[index];
return value ? Chartist.serialize(value.meta) : undefined;
};
/**
* Calculate the order of magnitude for the chart scale
*
* @memberof Chartist.Core
* @param {Number} value The value Range of the chart
* @return {Number} The order of magnitude
*/
Chartist.orderOfMagnitude = function (value) {
return Math.floor(Math.log(Math.abs(value)) / Math.LN10);
};
/**
* Project a data length into screen coordinates (pixels)
*
* @memberof Chartist.Core
* @param {Object} axisLength The svg element for the chart
* @param {Number} length Single data value from a series array
* @param {Object} bounds All the values to set the bounds of the chart
* @return {Number} The projected data length in pixels
*/
Chartist.projectLength = function (axisLength, length, bounds) {
return length / bounds.range * axisLength;
};
/**
* Get the height of the area in the chart for the data series
*
* @memberof Chartist.Core
* @param {Object} svg The svg element for the chart
* @param {Object} options The Object that contains all the optional values for the chart
* @return {Number} The height of the area in the chart for the data series
*/
Chartist.getAvailableHeight = function (svg, options) {
return Math.max((Chartist.quantity(options.height).value || svg.height()) - (options.chartPadding.top + options.chartPadding.bottom) - options.axisX.offset, 0);
};
/**
* Get highest and lowest value of data array. This Array contains the data that will be visualized in the chart.
*
* @memberof Chartist.Core
* @param {Array} data The array that contains the data to be visualized in the chart
* @param {Object} options The Object that contains the chart options
* @param {String} dimension Axis dimension 'x' or 'y' used to access the correct value and high / low configuration
* @return {Object} An object that contains the highest and lowest value that will be visualized on the chart.
*/
Chartist.getHighLow = function (data, options, dimension) {
// TODO: Remove workaround for deprecated global high / low config. Axis high / low configuration is preferred
options = Chartist.extend({}, options, dimension ? options['axis' + dimension.toUpperCase()] : {});
var highLow = {
high: options.high === undefined ? -Number.MAX_VALUE : +options.high,
low: options.low === undefined ? Number.MAX_VALUE : +options.low
};
var findHigh = options.high === undefined;
var findLow = options.low === undefined;
// Function to recursively walk through arrays and find highest and lowest number
function recursiveHighLow(data) {
if(data === undefined) {
return undefined;
} else if(data instanceof Array) {
for (var i = 0; i < data.length; i++) {
recursiveHighLow(data[i]);
}
} else {
var value = dimension ? +data[dimension] : +data;
if (findHigh && value > highLow.high) {
highLow.high = value;
}
if (findLow && value < highLow.low) {
highLow.low = value;
}
}
}
// Start to find highest and lowest number recursively
if(findHigh || findLow) {
recursiveHighLow(data);
}
// Overrides of high / low based on reference value, it will make sure that the invisible reference value is
// used to generate the chart. This is useful when the chart always needs to contain the position of the
// invisible reference value in the view i.e. for bipolar scales.
if (options.referenceValue || options.referenceValue === 0) {
highLow.high = Math.max(options.referenceValue, highLow.high);
highLow.low = Math.min(options.referenceValue, highLow.low);
}
// If high and low are the same because of misconfiguration or flat data (only the same value) we need
// to set the high or low to 0 depending on the polarity
if (highLow.high <= highLow.low) {
// If both values are 0 we set high to 1
if (highLow.low === 0) {
highLow.high = 1;
} else if (highLow.low < 0) {
// If we have the same negative value for the bounds we set bounds.high to 0
highLow.high = 0;
} else if (highLow.high > 0) {
// If we have the same positive value for the bounds we set bounds.low to 0
highLow.low = 0;
} else {
// If data array was empty, values are Number.MAX_VALUE and -Number.MAX_VALUE. Set bounds to prevent errors
highLow.high = 1;
highLow.low = 0;
}
}
return highLow;
};
/**
* Checks if the value is a valid number or string with a number.
*
* @memberof Chartist.Core
* @param value
* @returns {Boolean}
*/
Chartist.isNum = function(value) {
return !isNaN(value) && isFinite(value);
};
/**
* Returns true on all falsey values except the numeric value 0.
*
* @memberof Chartist.Core
* @param value
* @returns {boolean}
*/
Chartist.isFalseyButZero = function(value) {
return !value && value !== 0;
};
/**
* Returns a number if the passed parameter is a valid number or the function will return undefined. On all other values than a valid number, this function will return undefined.
*
* @memberof Chartist.Core
* @param value
* @returns {*}
*/
Chartist.getNumberOrUndefined = function(value) {
return isNaN(+value) ? undefined : +value;
};
/**
* Gets a value from a dimension `value.x` or `value.y` while returning value directly if it's a valid numeric value. If the value is not numeric and it's falsey this function will return undefined.
*
* @param value
* @param dimension
* @returns {*}
*/
Chartist.getMultiValue = function(value, dimension) {
if(Chartist.isNum(value)) {
return +value;
} else if(value) {
return value[dimension || 'y'] || 0;
} else {
return 0;
}
};
/**
* Pollard Rho Algorithm to find smallest factor of an integer value. There are more efficient algorithms for factorization, but this one is quite efficient and not so complex.
*
* @memberof Chartist.Core
* @param {Number} num An integer number where the smallest factor should be searched for
* @returns {Number} The smallest integer factor of the parameter num.
*/
Chartist.rho = function(num) {
if(num === 1) {
return num;
}
function gcd(p, q) {
if (p % q === 0) {
return q;
} else {
return gcd(q, p % q);
}
}
function f(x) {
return x * x + 1;
}
var x1 = 2, x2 = 2, divisor;
if (num % 2 === 0) {
return 2;
}
do {
x1 = f(x1) % num;
x2 = f(f(x2)) % num;
divisor = gcd(Math.abs(x1 - x2), num);
} while (divisor === 1);
return divisor;
};
/**
* Calculate and retrieve all the bounds for the chart and return them in one array
*
* @memberof Chartist.Core
* @param {Number} axisLength The length of the Axis used for
* @param {Object} highLow An object containing a high and low property indicating the value range of the chart.
* @param {Number} scaleMinSpace The minimum projected length a step should result in
* @param {Boolean} onlyInteger
* @return {Object} All the values to set the bounds of the chart
*/
Chartist.getBounds = function (axisLength, highLow, scaleMinSpace, onlyInteger) {
var i,
optimizationCounter = 0,
newMin,
newMax,
bounds = {
high: highLow.high,
low: highLow.low
};
bounds.valueRange = bounds.high - bounds.low;
bounds.oom = Chartist.orderOfMagnitude(bounds.valueRange);
bounds.step = Math.pow(10, bounds.oom);
bounds.min = Math.floor(bounds.low / bounds.step) * bounds.step;
bounds.max = Math.ceil(bounds.high / bounds.step) * bounds.step;
bounds.range = bounds.max - bounds.min;
bounds.numberOfSteps = Math.round(bounds.range / bounds.step);
// Optimize scale step by checking if subdivision is possible based on horizontalGridMinSpace
// If we are already below the scaleMinSpace value we will scale up
var length = Chartist.projectLength(axisLength, bounds.step, bounds);
var scaleUp = length < scaleMinSpace;
var smallestFactor = onlyInteger ? Chartist.rho(bounds.range) : 0;
// First check if we should only use integer steps and if step 1 is still larger than scaleMinSpace so we can use 1
if(onlyInteger && Chartist.projectLength(axisLength, 1, bounds) >= scaleMinSpace) {
bounds.step = 1;
} else if(onlyInteger && smallestFactor < bounds.step && Chartist.projectLength(axisLength, smallestFactor, bounds) >= scaleMinSpace) {
// If step 1 was too small, we can try the smallest factor of range
// If the smallest factor is smaller than the current bounds.step and the projected length of smallest factor
// is larger than the scaleMinSpace we should go for it.
bounds.step = smallestFactor;
} else {
// Trying to divide or multiply by 2 and find the best step value
while (true) {
if (scaleUp && Chartist.projectLength(axisLength, bounds.step, bounds) <= scaleMinSpace) {
bounds.step *= 2;
} else if (!scaleUp && Chartist.projectLength(axisLength, bounds.step / 2, bounds) >= scaleMinSpace) {
bounds.step /= 2;
if(onlyInteger && bounds.step % 1 !== 0) {
bounds.step *= 2;
break;
}
} else {
break;
}
if(optimizationCounter++ > 1000) {
throw new Error('Exceeded maximum number of iterations while optimizing scale step!');
}
}
}
// step must not be less than EPSILON to create values that can be represented as floating number.
var EPSILON = 2.221E-16;
bounds.step = Math.max(bounds.step, EPSILON);
// Narrow min and max based on new step
newMin = bounds.min;
newMax = bounds.max;
while(newMin + bounds.step <= bounds.low) {
newMin += bounds.step;
}
while(newMax - bounds.step >= bounds.high) {
newMax -= bounds.step;
}
bounds.min = newMin;
bounds.max = newMax;
bounds.range = bounds.max - bounds.min;
var values = [];
for (i = bounds.min; i <= bounds.max; i += bounds.step) {
var value = Chartist.roundWithPrecision(i);
if (value !== values[values.length - 1]) {
values.push(i);
}
}
bounds.values = values;
return bounds;
};
/**
* Calculate cartesian coordinates of polar coordinates
*
* @memberof Chartist.Core
* @param {Number} centerX X-axis coordinates of center point of circle segment
* @param {Number} centerY X-axis coordinates of center point of circle segment
* @param {Number} radius Radius of circle segment
* @param {Number} angleInDegrees Angle of circle segment in degrees
* @return {{x:Number, y:Number}} Coordinates of point on circumference
*/
Chartist.polarToCartesian = function (centerX, centerY, radius, angleInDegrees) {
var angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
};
/**
* Initialize chart drawing rectangle (area where chart is drawn) x1,y1 = bottom left / x2,y2 = top right
*
* @memberof Chartist.Core
* @param {Object} svg The svg element for the chart
* @param {Object} options The Object that contains all the optional values for the chart
* @param {Number} [fallbackPadding] The fallback padding if partial padding objects are used
* @return {Object} The chart rectangles coordinates inside the svg element plus the rectangles measurements
*/
Chartist.createChartRect = function (svg, options, fallbackPadding) {
var hasAxis = !!(options.axisX || options.axisY);
var yAxisOffset = hasAxis ? options.axisY.offset : 0;
var xAxisOffset = hasAxis ? options.axisX.offset : 0;
// If width or height results in invalid value (including 0) we fallback to the unitless settings or even 0
var width = svg.width() || Chartist.quantity(options.width).value || 0;
var height = svg.height() || Chartist.quantity(options.height).value || 0;
var normalizedPadding = Chartist.normalizePadding(options.chartPadding, fallbackPadding);
// If settings were to small to cope with offset (legacy) and padding, we'll adjust
width = Math.max(width, yAxisOffset + normalizedPadding.left + normalizedPadding.right);
height = Math.max(height, xAxisOffset + normalizedPadding.top + normalizedPadding.bottom);
var chartRect = {
padding: normalizedPadding,
width: function () {
return this.x2 - this.x1;
},
height: function () {
return this.y1 - this.y2;
}
};
if(hasAxis) {
if (options.axisX.position === 'start') {
chartRect.y2 = normalizedPadding.top + xAxisOffset;
chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);
} else {
chartRect.y2 = normalizedPadding.top;
chartRect.y1 = Math.max(height - normalizedPadding.bottom - xAxisOffset, chartRect.y2 + 1);
}
if (options.axisY.position === 'start') {
chartRect.x1 = normalizedPadding.left + yAxisOffset;
chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);
} else {
chartRect.x1 = normalizedPadding.left;
chartRect.x2 = Math.max(width - normalizedPadding.right - yAxisOffset, chartRect.x1 + 1);
}
} else {
chartRect.x1 = normalizedPadding.left;
chartRect.x2 = Math.max(width - normalizedPadding.right, chartRect.x1 + 1);
chartRect.y2 = normalizedPadding.top;
chartRect.y1 = Math.max(height - normalizedPadding.bottom, chartRect.y2 + 1);
}
return chartRect;
};
/**
* Creates a grid line based on a projected value.
*
* @memberof Chartist.Core
* @param position
* @param index
* @param axis
* @param offset
* @param length
* @param group
* @param classes
* @param eventEmitter
*/
Chartist.createGrid = function(position, index, axis, offset, length, group, classes, eventEmitter) {
var positionalData = {};
positionalData[axis.units.pos + '1'] = position;
positionalData[axis.units.pos + '2'] = position;
positionalData[axis.counterUnits.pos + '1'] = offset;
positionalData[axis.counterUnits.pos + '2'] = offset + length;
var gridElement = group.elem('line', positionalData, classes.join(' '));
// Event for grid draw
eventEmitter.emit('draw',
Chartist.extend({
type: 'grid',
axis: axis,
index: index,
group: group,
element: gridElement
}, positionalData)
);
};
/**
* Creates a label based on a projected value and an axis.
*
* @memberof Chartist.Core
* @param position
* @param length
* @param index
* @param labels
* @param axis
* @param axisOffset
* @param labelOffset
* @param group
* @param classes
* @param useForeignObject
* @param eventEmitter
*/
Chartist.createLabel = function(position, length, index, labels, axis, axisOffset, labelOffset, group, classes, useForeignObject, eventEmitter) {
var labelElement;
var positionalData = {};
positionalData[axis.units.pos] = position + labelOffset[axis.units.pos];
positionalData[axis.counterUnits.pos] = labelOffset[axis.counterUnits.pos];
positionalData[axis.units.len] = length;
positionalData[axis.counterUnits.len] = Math.max(0, axisOffset - 10);
if(useForeignObject) {
// We need to set width and height explicitly to px as span will not expand with width and height being
// 100% in all browsers
var content = '
' +
labels[index] + ' ';
labelElement = group.foreignObject(content, Chartist.extend({
style: 'overflow: visible;'
}, positionalData));
} else {
labelElement = group.elem('text', positionalData, classes.join(' ')).text(labels[index]);
}
eventEmitter.emit('draw', Chartist.extend({
type: 'label',
axis: axis,
index: index,
group: group,
element: labelElement,
text: labels[index]
}, positionalData));
};
/**
* Helper to read series specific options from options object. It automatically falls back to the global option if
* there is no option in the series options.
*
* @param {Object} series Series object
* @param {Object} options Chartist options object
* @param {string} key The options key that should be used to obtain the options
* @returns {*}
*/
Chartist.getSeriesOption = function(series, options, key) {
if(series.name && options.series && options.series[series.name]) {
var seriesOptions = options.series[series.name];
return seriesOptions.hasOwnProperty(key) ? seriesOptions[key] : options[key];
} else {
return options[key];
}
};
/**
* Provides options handling functionality with callback for options changes triggered by responsive options and media query matches
*
* @memberof Chartist.Core
* @param {Object} options Options set by user
* @param {Array} responsiveOptions Optional functions to add responsive behavior to chart
* @param {Object} eventEmitter The event emitter that will be used to emit the options changed events
* @return {Object} The consolidated options object from the defaults, base and matching responsive options
*/
Chartist.optionsProvider = function (options, responsiveOptions, eventEmitter) {
var baseOptions = Chartist.extend({}, options),
currentOptions,
mediaQueryListeners = [],
i;
function updateCurrentOptions(mediaEvent) {
var previousOptions = currentOptions;
currentOptions = Chartist.extend({}, baseOptions);
if (responsiveOptions) {
for (i = 0; i < responsiveOptions.length; i++) {
var mql = window.matchMedia(responsiveOptions[i][0]);
if (mql.matches) {
currentOptions = Chartist.extend(currentOptions, responsiveOptions[i][1]);
}
}
}
if(eventEmitter && mediaEvent) {
eventEmitter.emit('optionsChanged', {
previousOptions: previousOptions,
currentOptions: currentOptions
});
}
}
function removeMediaQueryListeners() {
mediaQueryListeners.forEach(function(mql) {
mql.removeListener(updateCurrentOptions);
});
}
if (!window.matchMedia) {
throw 'window.matchMedia not found! Make sure you\'re using a polyfill.';
} else if (responsiveOptions) {
for (i = 0; i < responsiveOptions.length; i++) {
var mql = window.matchMedia(responsiveOptions[i][0]);
mql.addListener(updateCurrentOptions);
mediaQueryListeners.push(mql);
}
}
// Execute initially without an event argument so we get the correct options
updateCurrentOptions();
return {
removeMediaQueryListeners: removeMediaQueryListeners,
getCurrentOptions: function getCurrentOptions() {
return Chartist.extend({}, currentOptions);
}
};
};
/**
* Splits a list of coordinates and associated values into segments. Each returned segment contains a pathCoordinates
* valueData property describing the segment.
*
* With the default options, segments consist of contiguous sets of points that do not have an undefined value. Any
* points with undefined values are discarded.
*
* **Options**
* The following options are used to determine how segments are formed
* ```javascript
* var options = {
* // If fillHoles is true, undefined values are simply discarded without creating a new segment. Assuming other options are default, this returns single segment.
* fillHoles: false,
* // If increasingX is true, the coordinates in all segments have strictly increasing x-values.
* increasingX: false
* };
* ```
*
* @memberof Chartist.Core
* @param {Array} pathCoordinates List of point coordinates to be split in the form [x1, y1, x2, y2 ... xn, yn]
* @param {Array} values List of associated point values in the form [v1, v2 .. vn]
* @param {Object} options Options set by user
* @return {Array} List of segments, each containing a pathCoordinates and valueData property.
*/
Chartist.splitIntoSegments = function(pathCoordinates, valueData, options) {
var defaultOptions = {
increasingX: false,
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
var segments = [];
var hole = true;
for(var i = 0; i < pathCoordinates.length; i += 2) {
// If this value is a "hole" we set the hole flag
if(valueData[i / 2].value === undefined) {
if(!options.fillHoles) {
hole = true;
}
} else {
if(options.increasingX && i >= 2 && pathCoordinates[i] <= pathCoordinates[i-2]) {
// X is not increasing, so we need to make sure we start a new segment
hole = true;
}
// If it's a valid value we need to check if we're coming out of a hole and create a new empty segment
if(hole) {
segments.push({
pathCoordinates: [],
valueData: []
});
// As we have a valid value now, we are not in a "hole" anymore
hole = false;
}
// Add to the segment pathCoordinates and valueData
segments[segments.length - 1].pathCoordinates.push(pathCoordinates[i], pathCoordinates[i + 1]);
segments[segments.length - 1].valueData.push(valueData[i / 2]);
}
}
return segments;
};
}(window, document, Chartist));
;/**
* Chartist path interpolation functions.
*
* @module Chartist.Interpolation
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
Chartist.Interpolation = {};
/**
* This interpolation function does not smooth the path and the result is only containing lines and no curves.
*
* @example
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [[1, 2, 8, 1, 7]]
* }, {
* lineSmooth: Chartist.Interpolation.none({
* fillHoles: false
* })
* });
*
*
* @memberof Chartist.Interpolation
* @return {Function}
*/
Chartist.Interpolation.none = function(options) {
var defaultOptions = {
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
return function none(pathCoordinates, valueData) {
var path = new Chartist.Svg.Path();
var hole = true;
for(var i = 0; i < pathCoordinates.length; i += 2) {
var currX = pathCoordinates[i];
var currY = pathCoordinates[i + 1];
var currData = valueData[i / 2];
if(currData.value !== undefined) {
if(hole) {
path.move(currX, currY, false, currData);
} else {
path.line(currX, currY, false, currData);
}
hole = false;
} else if(!options.fillHoles) {
hole = true;
}
}
return path;
};
};
/**
* Simple smoothing creates horizontal handles that are positioned with a fraction of the length between two data points. You can use the divisor option to specify the amount of smoothing.
*
* Simple smoothing can be used instead of `Chartist.Smoothing.cardinal` if you'd like to get rid of the artifacts it produces sometimes. Simple smoothing produces less flowing lines but is accurate by hitting the points and it also doesn't swing below or above the given data point.
*
* All smoothing functions within Chartist are factory functions that accept an options parameter. The simple interpolation function accepts one configuration parameter `divisor`, between 1 and ∞, which controls the smoothing characteristics.
*
* @example
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [[1, 2, 8, 1, 7]]
* }, {
* lineSmooth: Chartist.Interpolation.simple({
* divisor: 2,
* fillHoles: false
* })
* });
*
*
* @memberof Chartist.Interpolation
* @param {Object} options The options of the simple interpolation factory function.
* @return {Function}
*/
Chartist.Interpolation.simple = function(options) {
var defaultOptions = {
divisor: 2,
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
var d = 1 / Math.max(1, options.divisor);
return function simple(pathCoordinates, valueData) {
var path = new Chartist.Svg.Path();
var prevX, prevY, prevData;
for(var i = 0; i < pathCoordinates.length; i += 2) {
var currX = pathCoordinates[i];
var currY = pathCoordinates[i + 1];
var length = (currX - prevX) * d;
var currData = valueData[i / 2];
if(currData.value !== undefined) {
if(prevData === undefined) {
path.move(currX, currY, false, currData);
} else {
path.curve(
prevX + length,
prevY,
currX - length,
currY,
currX,
currY,
false,
currData
);
}
prevX = currX;
prevY = currY;
prevData = currData;
} else if(!options.fillHoles) {
prevX = currX = prevData = undefined;
}
}
return path;
};
};
/**
* Cardinal / Catmull-Rome spline interpolation is the default smoothing function in Chartist. It produces nice results where the splines will always meet the points. It produces some artifacts though when data values are increased or decreased rapidly. The line may not follow a very accurate path and if the line should be accurate this smoothing function does not produce the best results.
*
* Cardinal splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.
*
* All smoothing functions within Chartist are factory functions that accept an options parameter. The cardinal interpolation function accepts one configuration parameter `tension`, between 0 and 1, which controls the smoothing intensity.
*
* @example
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [[1, 2, 8, 1, 7]]
* }, {
* lineSmooth: Chartist.Interpolation.cardinal({
* tension: 1,
* fillHoles: false
* })
* });
*
* @memberof Chartist.Interpolation
* @param {Object} options The options of the cardinal factory function.
* @return {Function}
*/
Chartist.Interpolation.cardinal = function(options) {
var defaultOptions = {
tension: 1,
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
var t = Math.min(1, Math.max(0, options.tension)),
c = 1 - t;
return function cardinal(pathCoordinates, valueData) {
// First we try to split the coordinates into segments
// This is necessary to treat "holes" in line charts
var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {
fillHoles: options.fillHoles
});
if(!segments.length) {
// If there were no segments return 'Chartist.Interpolation.none'
return Chartist.Interpolation.none()([]);
} else if(segments.length > 1) {
// If the split resulted in more that one segment we need to interpolate each segment individually and join them
// afterwards together into a single path.
var paths = [];
// For each segment we will recurse the cardinal function
segments.forEach(function(segment) {
paths.push(cardinal(segment.pathCoordinates, segment.valueData));
});
// Join the segment path data into a single path and return
return Chartist.Svg.Path.join(paths);
} else {
// If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first
// segment
pathCoordinates = segments[0].pathCoordinates;
valueData = segments[0].valueData;
// If less than two points we need to fallback to no smoothing
if(pathCoordinates.length <= 4) {
return Chartist.Interpolation.none()(pathCoordinates, valueData);
}
var path = new Chartist.Svg.Path().move(pathCoordinates[0], pathCoordinates[1], false, valueData[0]),
z;
for (var i = 0, iLen = pathCoordinates.length; iLen - 2 * !z > i; i += 2) {
var p = [
{x: +pathCoordinates[i - 2], y: +pathCoordinates[i - 1]},
{x: +pathCoordinates[i], y: +pathCoordinates[i + 1]},
{x: +pathCoordinates[i + 2], y: +pathCoordinates[i + 3]},
{x: +pathCoordinates[i + 4], y: +pathCoordinates[i + 5]}
];
if (z) {
if (!i) {
p[0] = {x: +pathCoordinates[iLen - 2], y: +pathCoordinates[iLen - 1]};
} else if (iLen - 4 === i) {
p[3] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};
} else if (iLen - 2 === i) {
p[2] = {x: +pathCoordinates[0], y: +pathCoordinates[1]};
p[3] = {x: +pathCoordinates[2], y: +pathCoordinates[3]};
}
} else {
if (iLen - 4 === i) {
p[3] = p[2];
} else if (!i) {
p[0] = {x: +pathCoordinates[i], y: +pathCoordinates[i + 1]};
}
}
path.curve(
(t * (-p[0].x + 6 * p[1].x + p[2].x) / 6) + (c * p[2].x),
(t * (-p[0].y + 6 * p[1].y + p[2].y) / 6) + (c * p[2].y),
(t * (p[1].x + 6 * p[2].x - p[3].x) / 6) + (c * p[2].x),
(t * (p[1].y + 6 * p[2].y - p[3].y) / 6) + (c * p[2].y),
p[2].x,
p[2].y,
false,
valueData[(i + 2) / 2]
);
}
return path;
}
};
};
/**
* Monotone Cubic spline interpolation produces a smooth curve which preserves monotonicity. Unlike cardinal splines, the curve will not extend beyond the range of y-values of the original data points.
*
* Monotone Cubic splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.
*
* The x-values of subsequent points must be increasing to fit a Monotone Cubic spline. If this condition is not met for a pair of adjacent points, then there will be a break in the curve between those data points.
*
* All smoothing functions within Chartist are factory functions that accept an options parameter.
*
* @example
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [[1, 2, 8, 1, 7]]
* }, {
* lineSmooth: Chartist.Interpolation.monotoneCubic({
* fillHoles: false
* })
* });
*
* @memberof Chartist.Interpolation
* @param {Object} options The options of the monotoneCubic factory function.
* @return {Function}
*/
Chartist.Interpolation.monotoneCubic = function(options) {
var defaultOptions = {
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
return function monotoneCubic(pathCoordinates, valueData) {
// First we try to split the coordinates into segments
// This is necessary to treat "holes" in line charts
var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {
fillHoles: options.fillHoles,
increasingX: true
});
if(!segments.length) {
// If there were no segments return 'Chartist.Interpolation.none'
return Chartist.Interpolation.none()([]);
} else if(segments.length > 1) {
// If the split resulted in more that one segment we need to interpolate each segment individually and join them
// afterwards together into a single path.
var paths = [];
// For each segment we will recurse the monotoneCubic fn function
segments.forEach(function(segment) {
paths.push(monotoneCubic(segment.pathCoordinates, segment.valueData));
});
// Join the segment path data into a single path and return
return Chartist.Svg.Path.join(paths);
} else {
// If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first
// segment
pathCoordinates = segments[0].pathCoordinates;
valueData = segments[0].valueData;
// If less than three points we need to fallback to no smoothing
if(pathCoordinates.length <= 4) {
return Chartist.Interpolation.none()(pathCoordinates, valueData);
}
var xs = [],
ys = [],
i,
n = pathCoordinates.length / 2,
ms = [],
ds = [], dys = [], dxs = [],
path;
// Populate x and y coordinates into separate arrays, for readability
for(i = 0; i < n; i++) {
xs[i] = pathCoordinates[i * 2];
ys[i] = pathCoordinates[i * 2 + 1];
}
// Calculate deltas and derivative
for(i = 0; i < n - 1; i++) {
dys[i] = ys[i + 1] - ys[i];
dxs[i] = xs[i + 1] - xs[i];
ds[i] = dys[i] / dxs[i];
}
// Determine desired slope (m) at each point using Fritsch-Carlson method
// See: http://math.stackexchange.com/questions/45218/implementation-of-monotone-cubic-interpolation
ms[0] = ds[0];
ms[n - 1] = ds[n - 2];
for(i = 1; i < n - 1; i++) {
if(ds[i] === 0 || ds[i - 1] === 0 || (ds[i - 1] > 0) !== (ds[i] > 0)) {
ms[i] = 0;
} else {
ms[i] = 3 * (dxs[i - 1] + dxs[i]) / (
(2 * dxs[i] + dxs[i - 1]) / ds[i - 1] +
(dxs[i] + 2 * dxs[i - 1]) / ds[i]);
if(!isFinite(ms[i])) {
ms[i] = 0;
}
}
}
// Now build a path from the slopes
path = new Chartist.Svg.Path().move(xs[0], ys[0], false, valueData[0]);
for(i = 0; i < n - 1; i++) {
path.curve(
// First control point
xs[i] + dxs[i] / 3,
ys[i] + ms[i] * dxs[i] / 3,
// Second control point
xs[i + 1] - dxs[i] / 3,
ys[i + 1] - ms[i + 1] * dxs[i] / 3,
// End point
xs[i + 1],
ys[i + 1],
false,
valueData[i + 1]
);
}
return path;
}
};
};
/**
* Step interpolation will cause the line chart to move in steps rather than diagonal or smoothed lines. This interpolation will create additional points that will also be drawn when the `showPoint` option is enabled.
*
* All smoothing functions within Chartist are factory functions that accept an options parameter. The step interpolation function accepts one configuration parameter `postpone`, that can be `true` or `false`. The default value is `true` and will cause the step to occur where the value actually changes. If a different behaviour is needed where the step is shifted to the left and happens before the actual value, this option can be set to `false`.
*
* @example
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [[1, 2, 8, 1, 7]]
* }, {
* lineSmooth: Chartist.Interpolation.step({
* postpone: true,
* fillHoles: false
* })
* });
*
* @memberof Chartist.Interpolation
* @param options
* @returns {Function}
*/
Chartist.Interpolation.step = function(options) {
var defaultOptions = {
postpone: true,
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
return function step(pathCoordinates, valueData) {
var path = new Chartist.Svg.Path();
var prevX, prevY, prevData;
for (var i = 0; i < pathCoordinates.length; i += 2) {
var currX = pathCoordinates[i];
var currY = pathCoordinates[i + 1];
var currData = valueData[i / 2];
// If the current point is also not a hole we can draw the step lines
if(currData.value !== undefined) {
if(prevData === undefined) {
path.move(currX, currY, false, currData);
} else {
if(options.postpone) {
// If postponed we should draw the step line with the value of the previous value
path.line(currX, prevY, false, prevData);
} else {
// If not postponed we should draw the step line with the value of the current value
path.line(prevX, currY, false, currData);
}
// Line to the actual point (this should only be a Y-Axis movement
path.line(currX, currY, false, currData);
}
prevX = currX;
prevY = currY;
prevData = currData;
} else if(!options.fillHoles) {
prevX = prevY = prevData = undefined;
}
}
return path;
};
};
}(window, document, Chartist));
;/**
* A very basic event module that helps to generate and catch events.
*
* @module Chartist.Event
*/
/* global Chartist */
(function (window, document, Chartist) {
'use strict';
Chartist.EventEmitter = function () {
var handlers = [];
/**
* Add an event handler for a specific event
*
* @memberof Chartist.Event
* @param {String} event The event name
* @param {Function} handler A event handler function
*/
function addEventHandler(event, handler) {
handlers[event] = handlers[event] || [];
handlers[event].push(handler);
}
/**
* Remove an event handler of a specific event name or remove all event handlers for a specific event.
*
* @memberof Chartist.Event
* @param {String} event The event name where a specific or all handlers should be removed
* @param {Function} [handler] An optional event handler function. If specified only this specific handler will be removed and otherwise all handlers are removed.
*/
function removeEventHandler(event, handler) {
// Only do something if there are event handlers with this name existing
if(handlers[event]) {
// If handler is set we will look for a specific handler and only remove this
if(handler) {
handlers[event].splice(handlers[event].indexOf(handler), 1);
if(handlers[event].length === 0) {
delete handlers[event];
}
} else {
// If no handler is specified we remove all handlers for this event
delete handlers[event];
}
}
}
/**
* Use this function to emit an event. All handlers that are listening for this event will be triggered with the data parameter.
*
* @memberof Chartist.Event
* @param {String} event The event name that should be triggered
* @param {*} data Arbitrary data that will be passed to the event handler callback functions
*/
function emit(event, data) {
// Only do something if there are event handlers with this name existing
if(handlers[event]) {
handlers[event].forEach(function(handler) {
handler(data);
});
}
// Emit event to star event handlers
if(handlers['*']) {
handlers['*'].forEach(function(starHandler) {
starHandler(event, data);
});
}
}
return {
addEventHandler: addEventHandler,
removeEventHandler: removeEventHandler,
emit: emit
};
};
}(window, document, Chartist));
;/**
* This module provides some basic prototype inheritance utilities.
*
* @module Chartist.Class
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
function listToArray(list) {
var arr = [];
if (list.length) {
for (var i = 0; i < list.length; i++) {
arr.push(list[i]);
}
}
return arr;
}
/**
* Method to extend from current prototype.
*
* @memberof Chartist.Class
* @param {Object} properties The object that serves as definition for the prototype that gets created for the new class. This object should always contain a constructor property that is the desired constructor for the newly created class.
* @param {Object} [superProtoOverride] By default extens will use the current class prototype or Chartist.class. With this parameter you can specify any super prototype that will be used.
* @return {Function} Constructor function of the new class
*
* @example
* var Fruit = Class.extend({
* color: undefined,
* sugar: undefined,
*
* constructor: function(color, sugar) {
* this.color = color;
* this.sugar = sugar;
* },
*
* eat: function() {
* this.sugar = 0;
* return this;
* }
* });
*
* var Banana = Fruit.extend({
* length: undefined,
*
* constructor: function(length, sugar) {
* Banana.super.constructor.call(this, 'Yellow', sugar);
* this.length = length;
* }
* });
*
* var banana = new Banana(20, 40);
* console.log('banana instanceof Fruit', banana instanceof Fruit);
* console.log('Fruit is prototype of banana', Fruit.prototype.isPrototypeOf(banana));
* console.log('bananas prototype is Fruit', Object.getPrototypeOf(banana) === Fruit.prototype);
* console.log(banana.sugar);
* console.log(banana.eat().sugar);
* console.log(banana.color);
*/
function extend(properties, superProtoOverride) {
var superProto = superProtoOverride || this.prototype || Chartist.Class;
var proto = Object.create(superProto);
Chartist.Class.cloneDefinitions(proto, properties);
var constr = function() {
var fn = proto.constructor || function () {},
instance;
// If this is linked to the Chartist namespace the constructor was not called with new
// To provide a fallback we will instantiate here and return the instance
instance = this === Chartist ? Object.create(proto) : this;
fn.apply(instance, Array.prototype.slice.call(arguments, 0));
// If this constructor was not called with new we need to return the instance
// This will not harm when the constructor has been called with new as the returned value is ignored
return instance;
};
constr.prototype = proto;
constr.super = superProto;
constr.extend = this.extend;
return constr;
}
// Variable argument list clones args > 0 into args[0] and retruns modified args[0]
function cloneDefinitions() {
var args = listToArray(arguments);
var target = args[0];
args.splice(1, args.length - 1).forEach(function (source) {
Object.getOwnPropertyNames(source).forEach(function (propName) {
// If this property already exist in target we delete it first
delete target[propName];
// Define the property with the descriptor from source
Object.defineProperty(target, propName,
Object.getOwnPropertyDescriptor(source, propName));
});
});
return target;
}
Chartist.Class = {
extend: extend,
cloneDefinitions: cloneDefinitions
};
}(window, document, Chartist));
;/**
* Base for all chart types. The methods in Chartist.Base are inherited to all chart types.
*
* @module Chartist.Base
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
// TODO: Currently we need to re-draw the chart on window resize. This is usually very bad and will affect performance.
// This is done because we can't work with relative coordinates when drawing the chart because SVG Path does not
// work with relative positions yet. We need to check if we can do a viewBox hack to switch to percentage.
// See http://mozilla.6506.n7.nabble.com/Specyfing-paths-with-percentages-unit-td247474.html
// Update: can be done using the above method tested here: http://codepen.io/gionkunz/pen/KDvLj
// The problem is with the label offsets that can't be converted into percentage and affecting the chart container
/**
* Updates the chart which currently does a full reconstruction of the SVG DOM
*
* @param {Object} [data] Optional data you'd like to set for the chart before it will update. If not specified the update method will use the data that is already configured with the chart.
* @param {Object} [options] Optional options you'd like to add to the previous options for the chart before it will update. If not specified the update method will use the options that have been already configured with the chart.
* @param {Boolean} [override] If set to true, the passed options will be used to extend the options that have been configured already. Otherwise the chart default options will be used as the base
* @memberof Chartist.Base
*/
function update(data, options, override) {
if(data) {
this.data = data;
// Event for data transformation that allows to manipulate the data before it gets rendered in the charts
this.eventEmitter.emit('data', {
type: 'update',
data: this.data
});
}
if(options) {
this.options = Chartist.extend({}, override ? this.options : this.defaultOptions, options);
// If chartist was not initialized yet, we just set the options and leave the rest to the initialization
// Otherwise we re-create the optionsProvider at this point
if(!this.initializeTimeoutId) {
this.optionsProvider.removeMediaQueryListeners();
this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);
}
}
// Only re-created the chart if it has been initialized yet
if(!this.initializeTimeoutId) {
this.createChart(this.optionsProvider.getCurrentOptions());
}
// Return a reference to the chart object to chain up calls
return this;
}
/**
* This method can be called on the API object of each chart and will un-register all event listeners that were added to other components. This currently includes a window.resize listener as well as media query listeners if any responsive options have been provided. Use this function if you need to destroy and recreate Chartist charts dynamically.
*
* @memberof Chartist.Base
*/
function detach() {
// Only detach if initialization already occurred on this chart. If this chart still hasn't initialized (therefore
// the initializationTimeoutId is still a valid timeout reference, we will clear the timeout
if(!this.initializeTimeoutId) {
window.removeEventListener('resize', this.resizeListener);
this.optionsProvider.removeMediaQueryListeners();
} else {
window.clearTimeout(this.initializeTimeoutId);
}
return this;
}
/**
* Use this function to register event handlers. The handler callbacks are synchronous and will run in the main thread rather than the event loop.
*
* @memberof Chartist.Base
* @param {String} event Name of the event. Check the examples for supported events.
* @param {Function} handler The handler function that will be called when an event with the given name was emitted. This function will receive a data argument which contains event data. See the example for more details.
*/
function on(event, handler) {
this.eventEmitter.addEventHandler(event, handler);
return this;
}
/**
* Use this function to un-register event handlers. If the handler function parameter is omitted all handlers for the given event will be un-registered.
*
* @memberof Chartist.Base
* @param {String} event Name of the event for which a handler should be removed
* @param {Function} [handler] The handler function that that was previously used to register a new event handler. This handler will be removed from the event handler list. If this parameter is omitted then all event handlers for the given event are removed from the list.
*/
function off(event, handler) {
this.eventEmitter.removeEventHandler(event, handler);
return this;
}
function initialize() {
// Add window resize listener that re-creates the chart
window.addEventListener('resize', this.resizeListener);
// Obtain current options based on matching media queries (if responsive options are given)
// This will also register a listener that is re-creating the chart based on media changes
this.optionsProvider = Chartist.optionsProvider(this.options, this.responsiveOptions, this.eventEmitter);
// Register options change listener that will trigger a chart update
this.eventEmitter.addEventHandler('optionsChanged', function() {
this.update();
}.bind(this));
// Before the first chart creation we need to register us with all plugins that are configured
// Initialize all relevant plugins with our chart object and the plugin options specified in the config
if(this.options.plugins) {
this.options.plugins.forEach(function(plugin) {
if(plugin instanceof Array) {
plugin[0](this, plugin[1]);
} else {
plugin(this);
}
}.bind(this));
}
// Event for data transformation that allows to manipulate the data before it gets rendered in the charts
this.eventEmitter.emit('data', {
type: 'initial',
data: this.data
});
// Create the first chart
this.createChart(this.optionsProvider.getCurrentOptions());
// As chart is initialized from the event loop now we can reset our timeout reference
// This is important if the chart gets initialized on the same element twice
this.initializeTimeoutId = undefined;
}
/**
* Constructor of chart base class.
*
* @param query
* @param data
* @param defaultOptions
* @param options
* @param responsiveOptions
* @constructor
*/
function Base(query, data, defaultOptions, options, responsiveOptions) {
this.container = Chartist.querySelector(query);
this.data = data;
this.defaultOptions = defaultOptions;
this.options = options;
this.responsiveOptions = responsiveOptions;
this.eventEmitter = Chartist.EventEmitter();
this.supportsForeignObject = Chartist.Svg.isSupported('Extensibility');
this.supportsAnimations = Chartist.Svg.isSupported('AnimationEventsAttribute');
this.resizeListener = function resizeListener(){
this.update();
}.bind(this);
if(this.container) {
// If chartist was already initialized in this container we are detaching all event listeners first
if(this.container.__chartist__) {
this.container.__chartist__.detach();
}
this.container.__chartist__ = this;
}
// Using event loop for first draw to make it possible to register event listeners in the same call stack where
// the chart was created.
this.initializeTimeoutId = setTimeout(initialize.bind(this), 0);
}
// Creating the chart base class
Chartist.Base = Chartist.Class.extend({
constructor: Base,
optionsProvider: undefined,
container: undefined,
svg: undefined,
eventEmitter: undefined,
createChart: function() {
throw new Error('Base chart type can\'t be instantiated!');
},
update: update,
detach: detach,
on: on,
off: off,
version: Chartist.version,
supportsForeignObject: false
});
}(window, document, Chartist));
;/**
* Chartist SVG module for simple SVG DOM abstraction
*
* @module Chartist.Svg
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
/**
* Chartist.Svg creates a new SVG object wrapper with a starting element. You can use the wrapper to fluently create sub-elements and modify them.
*
* @memberof Chartist.Svg
* @constructor
* @param {String|Element} name The name of the SVG element to create or an SVG dom element which should be wrapped into Chartist.Svg
* @param {Object} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.
* @param {String} className This class or class list will be added to the SVG element
* @param {Object} parent The parent SVG wrapper object where this newly created wrapper and it's element will be attached to as child
* @param {Boolean} insertFirst If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element
*/
function Svg(name, attributes, className, parent, insertFirst) {
// If Svg is getting called with an SVG element we just return the wrapper
if(name instanceof Element) {
this._node = name;
} else {
this._node = document.createElementNS(Chartist.namespaces.svg, name);
// If this is an SVG element created then custom namespace
if(name === 'svg') {
this.attr({
'xmlns:ct': Chartist.namespaces.ct
});
}
}
if(attributes) {
this.attr(attributes);
}
if(className) {
this.addClass(className);
}
if(parent) {
if (insertFirst && parent._node.firstChild) {
parent._node.insertBefore(this._node, parent._node.firstChild);
} else {
parent._node.appendChild(this._node);
}
}
}
/**
* Set attributes on the current SVG element of the wrapper you're currently working on.
*
* @memberof Chartist.Svg
* @param {Object|String} attributes An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added. If this parameter is a String then the function is used as a getter and will return the attribute value.
* @param {String} ns If specified, the attribute will be obtained using getAttributeNs. In order to write namepsaced attributes you can use the namespace:attribute notation within the attributes object.
* @return {Object|String} The current wrapper object will be returned so it can be used for chaining or the attribute value if used as getter function.
*/
function attr(attributes, ns) {
if(typeof attributes === 'string') {
if(ns) {
return this._node.getAttributeNS(ns, attributes);
} else {
return this._node.getAttribute(attributes);
}
}
Object.keys(attributes).forEach(function(key) {
// If the attribute value is undefined we can skip this one
if(attributes[key] === undefined) {
return;
}
if (key.indexOf(':') !== -1) {
var namespacedAttribute = key.split(':');
this._node.setAttributeNS(Chartist.namespaces[namespacedAttribute[0]], key, attributes[key]);
} else {
this._node.setAttribute(key, attributes[key]);
}
}.bind(this));
return this;
}
/**
* Create a new SVG element whose wrapper object will be selected for further operations. This way you can also create nested groups easily.
*
* @memberof Chartist.Svg
* @param {String} name The name of the SVG element that should be created as child element of the currently selected element wrapper
* @param {Object} [attributes] An object with properties that will be added as attributes to the SVG element that is created. Attributes with undefined values will not be added.
* @param {String} [className] This class or class list will be added to the SVG element
* @param {Boolean} [insertFirst] If this param is set to true in conjunction with a parent element the newly created element will be added as first child element in the parent element
* @return {Chartist.Svg} Returns a Chartist.Svg wrapper object that can be used to modify the containing SVG data
*/
function elem(name, attributes, className, insertFirst) {
return new Chartist.Svg(name, attributes, className, this, insertFirst);
}
/**
* Returns the parent Chartist.SVG wrapper object
*
* @memberof Chartist.Svg
* @return {Chartist.Svg} Returns a Chartist.Svg wrapper around the parent node of the current node. If the parent node is not existing or it's not an SVG node then this function will return null.
*/
function parent() {
return this._node.parentNode instanceof SVGElement ? new Chartist.Svg(this._node.parentNode) : null;
}
/**
* This method returns a Chartist.Svg wrapper around the root SVG element of the current tree.
*
* @memberof Chartist.Svg
* @return {Chartist.Svg} The root SVG element wrapped in a Chartist.Svg element
*/
function root() {
var node = this._node;
while(node.nodeName !== 'svg') {
node = node.parentNode;
}
return new Chartist.Svg(node);
}
/**
* Find the first child SVG element of the current element that matches a CSS selector. The returned object is a Chartist.Svg wrapper.
*
* @memberof Chartist.Svg
* @param {String} selector A CSS selector that is used to query for child SVG elements
* @return {Chartist.Svg} The SVG wrapper for the element found or null if no element was found
*/
function querySelector(selector) {
var foundNode = this._node.querySelector(selector);
return foundNode ? new Chartist.Svg(foundNode) : null;
}
/**
* Find the all child SVG elements of the current element that match a CSS selector. The returned object is a Chartist.Svg.List wrapper.
*
* @memberof Chartist.Svg
* @param {String} selector A CSS selector that is used to query for child SVG elements
* @return {Chartist.Svg.List} The SVG wrapper list for the element found or null if no element was found
*/
function querySelectorAll(selector) {
var foundNodes = this._node.querySelectorAll(selector);
return foundNodes.length ? new Chartist.Svg.List(foundNodes) : null;
}
/**
* This method creates a foreignObject (see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject) that allows to embed HTML content into a SVG graphic. With the help of foreignObjects you can enable the usage of regular HTML elements inside of SVG where they are subject for SVG positioning and transformation but the Browser will use the HTML rendering capabilities for the containing DOM.
*
* @memberof Chartist.Svg
* @param {Node|String} content The DOM Node, or HTML string that will be converted to a DOM Node, that is then placed into and wrapped by the foreignObject
* @param {String} [attributes] An object with properties that will be added as attributes to the foreignObject element that is created. Attributes with undefined values will not be added.
* @param {String} [className] This class or class list will be added to the SVG element
* @param {Boolean} [insertFirst] Specifies if the foreignObject should be inserted as first child
* @return {Chartist.Svg} New wrapper object that wraps the foreignObject element
*/
function foreignObject(content, attributes, className, insertFirst) {
// If content is string then we convert it to DOM
// TODO: Handle case where content is not a string nor a DOM Node
if(typeof content === 'string') {
var container = document.createElement('div');
container.innerHTML = content;
content = container.firstChild;
}
// Adding namespace to content element
content.setAttribute('xmlns', Chartist.namespaces.xmlns);
// Creating the foreignObject without required extension attribute (as described here
// http://www.w3.org/TR/SVG/extend.html#ForeignObjectElement)
var fnObj = this.elem('foreignObject', attributes, className, insertFirst);
// Add content to foreignObjectElement
fnObj._node.appendChild(content);
return fnObj;
}
/**
* This method adds a new text element to the current Chartist.Svg wrapper.
*
* @memberof Chartist.Svg
* @param {String} t The text that should be added to the text element that is created
* @return {Chartist.Svg} The same wrapper object that was used to add the newly created element
*/
function text(t) {
this._node.appendChild(document.createTextNode(t));
return this;
}
/**
* This method will clear all child nodes of the current wrapper object.
*
* @memberof Chartist.Svg
* @return {Chartist.Svg} The same wrapper object that got emptied
*/
function empty() {
while (this._node.firstChild) {
this._node.removeChild(this._node.firstChild);
}
return this;
}
/**
* This method will cause the current wrapper to remove itself from its parent wrapper. Use this method if you'd like to get rid of an element in a given DOM structure.
*
* @memberof Chartist.Svg
* @return {Chartist.Svg} The parent wrapper object of the element that got removed
*/
function remove() {
this._node.parentNode.removeChild(this._node);
return this.parent();
}
/**
* This method will replace the element with a new element that can be created outside of the current DOM.
*
* @memberof Chartist.Svg
* @param {Chartist.Svg} newElement The new Chartist.Svg object that will be used to replace the current wrapper object
* @return {Chartist.Svg} The wrapper of the new element
*/
function replace(newElement) {
this._node.parentNode.replaceChild(newElement._node, this._node);
return newElement;
}
/**
* This method will append an element to the current element as a child.
*
* @memberof Chartist.Svg
* @param {Chartist.Svg} element The Chartist.Svg element that should be added as a child
* @param {Boolean} [insertFirst] Specifies if the element should be inserted as first child
* @return {Chartist.Svg} The wrapper of the appended object
*/
function append(element, insertFirst) {
if(insertFirst && this._node.firstChild) {
this._node.insertBefore(element._node, this._node.firstChild);
} else {
this._node.appendChild(element._node);
}
return this;
}
/**
* Returns an array of class names that are attached to the current wrapper element. This method can not be chained further.
*
* @memberof Chartist.Svg
* @return {Array} A list of classes or an empty array if there are no classes on the current element
*/
function classes() {
return this._node.getAttribute('class') ? this._node.getAttribute('class').trim().split(/\s+/) : [];
}
/**
* Adds one or a space separated list of classes to the current element and ensures the classes are only existing once.
*
* @memberof Chartist.Svg
* @param {String} names A white space separated list of class names
* @return {Chartist.Svg} The wrapper of the current element
*/
function addClass(names) {
this._node.setAttribute('class',
this.classes(this._node)
.concat(names.trim().split(/\s+/))
.filter(function(elem, pos, self) {
return self.indexOf(elem) === pos;
}).join(' ')
);
return this;
}
/**
* Removes one or a space separated list of classes from the current element.
*
* @memberof Chartist.Svg
* @param {String} names A white space separated list of class names
* @return {Chartist.Svg} The wrapper of the current element
*/
function removeClass(names) {
var removedClasses = names.trim().split(/\s+/);
this._node.setAttribute('class', this.classes(this._node).filter(function(name) {
return removedClasses.indexOf(name) === -1;
}).join(' '));
return this;
}
/**
* Removes all classes from the current element.
*
* @memberof Chartist.Svg
* @return {Chartist.Svg} The wrapper of the current element
*/
function removeAllClasses() {
this._node.setAttribute('class', '');
return this;
}
/**
* Get element height using `getBoundingClientRect`
*
* @memberof Chartist.Svg
* @return {Number} The elements height in pixels
*/
function height() {
return this._node.getBoundingClientRect().height;
}
/**
* Get element width using `getBoundingClientRect`
*
* @memberof Chartist.Core
* @return {Number} The elements width in pixels
*/
function width() {
return this._node.getBoundingClientRect().width;
}
/**
* The animate function lets you animate the current element with SMIL animations. You can add animations for multiple attributes at the same time by using an animation definition object. This object should contain SMIL animation attributes. Please refer to http://www.w3.org/TR/SVG/animate.html for a detailed specification about the available animation attributes. Additionally an easing property can be passed in the animation definition object. This can be a string with a name of an easing function in `Chartist.Svg.Easing` or an array with four numbers specifying a cubic Bézier curve.
* **An animations object could look like this:**
* ```javascript
* element.animate({
* opacity: {
* dur: 1000,
* from: 0,
* to: 1
* },
* x1: {
* dur: '1000ms',
* from: 100,
* to: 200,
* easing: 'easeOutQuart'
* },
* y1: {
* dur: '2s',
* from: 0,
* to: 100
* }
* });
* ```
* **Automatic unit conversion**
* For the `dur` and the `begin` animate attribute you can also omit a unit by passing a number. The number will automatically be converted to milli seconds.
* **Guided mode**
* The default behavior of SMIL animations with offset using the `begin` attribute is that the attribute will keep it's original value until the animation starts. Mostly this behavior is not desired as you'd like to have your element attributes already initialized with the animation `from` value even before the animation starts. Also if you don't specify `fill="freeze"` on an animate element or if you delete the animation after it's done (which is done in guided mode) the attribute will switch back to the initial value. This behavior is also not desired when performing simple one-time animations. For one-time animations you'd want to trigger animations immediately instead of relative to the document begin time. That's why in guided mode Chartist.Svg will also use the `begin` property to schedule a timeout and manually start the animation after the timeout. If you're using multiple SMIL definition objects for an attribute (in an array), guided mode will be disabled for this attribute, even if you explicitly enabled it.
* If guided mode is enabled the following behavior is added:
* - Before the animation starts (even when delayed with `begin`) the animated attribute will be set already to the `from` value of the animation
* - `begin` is explicitly set to `indefinite` so it can be started manually without relying on document begin time (creation)
* - The animate element will be forced to use `fill="freeze"`
* - The animation will be triggered with `beginElement()` in a timeout where `begin` of the definition object is interpreted in milli seconds. If no `begin` was specified the timeout is triggered immediately.
* - After the animation the element attribute value will be set to the `to` value of the animation
* - The animate element is deleted from the DOM
*
* @memberof Chartist.Svg
* @param {Object} animations An animations object where the property keys are the attributes you'd like to animate. The properties should be objects again that contain the SMIL animation attributes (usually begin, dur, from, and to). The property begin and dur is auto converted (see Automatic unit conversion). You can also schedule multiple animations for the same attribute by passing an Array of SMIL definition objects. Attributes that contain an array of SMIL definition objects will not be executed in guided mode.
* @param {Boolean} guided Specify if guided mode should be activated for this animation (see Guided mode). If not otherwise specified, guided mode will be activated.
* @param {Object} eventEmitter If specified, this event emitter will be notified when an animation starts or ends.
* @return {Chartist.Svg} The current element where the animation was added
*/
function animate(animations, guided, eventEmitter) {
if(guided === undefined) {
guided = true;
}
Object.keys(animations).forEach(function createAnimateForAttributes(attribute) {
function createAnimate(animationDefinition, guided) {
var attributeProperties = {},
animate,
timeout,
easing;
// Check if an easing is specified in the definition object and delete it from the object as it will not
// be part of the animate element attributes.
if(animationDefinition.easing) {
// If already an easing Bézier curve array we take it or we lookup a easing array in the Easing object
easing = animationDefinition.easing instanceof Array ?
animationDefinition.easing :
Chartist.Svg.Easing[animationDefinition.easing];
delete animationDefinition.easing;
}
// If numeric dur or begin was provided we assume milli seconds
animationDefinition.begin = Chartist.ensureUnit(animationDefinition.begin, 'ms');
animationDefinition.dur = Chartist.ensureUnit(animationDefinition.dur, 'ms');
if(easing) {
animationDefinition.calcMode = 'spline';
animationDefinition.keySplines = easing.join(' ');
animationDefinition.keyTimes = '0;1';
}
// Adding "fill: freeze" if we are in guided mode and set initial attribute values
if(guided) {
animationDefinition.fill = 'freeze';
// Animated property on our element should already be set to the animation from value in guided mode
attributeProperties[attribute] = animationDefinition.from;
this.attr(attributeProperties);
// In guided mode we also set begin to indefinite so we can trigger the start manually and put the begin
// which needs to be in ms aside
timeout = Chartist.quantity(animationDefinition.begin || 0).value;
animationDefinition.begin = 'indefinite';
}
animate = this.elem('animate', Chartist.extend({
attributeName: attribute
}, animationDefinition));
if(guided) {
// If guided we take the value that was put aside in timeout and trigger the animation manually with a timeout
setTimeout(function() {
// If beginElement fails we set the animated attribute to the end position and remove the animate element
// This happens if the SMIL ElementTimeControl interface is not supported or any other problems occured in
// the browser. (Currently FF 34 does not support animate elements in foreignObjects)
try {
animate._node.beginElement();
} catch(err) {
// Set animated attribute to current animated value
attributeProperties[attribute] = animationDefinition.to;
this.attr(attributeProperties);
// Remove the animate element as it's no longer required
animate.remove();
}
}.bind(this), timeout);
}
if(eventEmitter) {
animate._node.addEventListener('beginEvent', function handleBeginEvent() {
eventEmitter.emit('animationBegin', {
element: this,
animate: animate._node,
params: animationDefinition
});
}.bind(this));
}
animate._node.addEventListener('endEvent', function handleEndEvent() {
if(eventEmitter) {
eventEmitter.emit('animationEnd', {
element: this,
animate: animate._node,
params: animationDefinition
});
}
if(guided) {
// Set animated attribute to current animated value
attributeProperties[attribute] = animationDefinition.to;
this.attr(attributeProperties);
// Remove the animate element as it's no longer required
animate.remove();
}
}.bind(this));
}
// If current attribute is an array of definition objects we create an animate for each and disable guided mode
if(animations[attribute] instanceof Array) {
animations[attribute].forEach(function(animationDefinition) {
createAnimate.bind(this)(animationDefinition, false);
}.bind(this));
} else {
createAnimate.bind(this)(animations[attribute], guided);
}
}.bind(this));
return this;
}
Chartist.Svg = Chartist.Class.extend({
constructor: Svg,
attr: attr,
elem: elem,
parent: parent,
root: root,
querySelector: querySelector,
querySelectorAll: querySelectorAll,
foreignObject: foreignObject,
text: text,
empty: empty,
remove: remove,
replace: replace,
append: append,
classes: classes,
addClass: addClass,
removeClass: removeClass,
removeAllClasses: removeAllClasses,
height: height,
width: width,
animate: animate
});
/**
* This method checks for support of a given SVG feature like Extensibility, SVG-animation or the like. Check http://www.w3.org/TR/SVG11/feature for a detailed list.
*
* @memberof Chartist.Svg
* @param {String} feature The SVG 1.1 feature that should be checked for support.
* @return {Boolean} True of false if the feature is supported or not
*/
Chartist.Svg.isSupported = function(feature) {
return document.implementation.hasFeature('http://www.w3.org/TR/SVG11/feature#' + feature, '1.1');
};
/**
* This Object contains some standard easing cubic bezier curves. Then can be used with their name in the `Chartist.Svg.animate`. You can also extend the list and use your own name in the `animate` function. Click the show code button to see the available bezier functions.
*
* @memberof Chartist.Svg
*/
var easingCubicBeziers = {
easeInSine: [0.47, 0, 0.745, 0.715],
easeOutSine: [0.39, 0.575, 0.565, 1],
easeInOutSine: [0.445, 0.05, 0.55, 0.95],
easeInQuad: [0.55, 0.085, 0.68, 0.53],
easeOutQuad: [0.25, 0.46, 0.45, 0.94],
easeInOutQuad: [0.455, 0.03, 0.515, 0.955],
easeInCubic: [0.55, 0.055, 0.675, 0.19],
easeOutCubic: [0.215, 0.61, 0.355, 1],
easeInOutCubic: [0.645, 0.045, 0.355, 1],
easeInQuart: [0.895, 0.03, 0.685, 0.22],
easeOutQuart: [0.165, 0.84, 0.44, 1],
easeInOutQuart: [0.77, 0, 0.175, 1],
easeInQuint: [0.755, 0.05, 0.855, 0.06],
easeOutQuint: [0.23, 1, 0.32, 1],
easeInOutQuint: [0.86, 0, 0.07, 1],
easeInExpo: [0.95, 0.05, 0.795, 0.035],
easeOutExpo: [0.19, 1, 0.22, 1],
easeInOutExpo: [1, 0, 0, 1],
easeInCirc: [0.6, 0.04, 0.98, 0.335],
easeOutCirc: [0.075, 0.82, 0.165, 1],
easeInOutCirc: [0.785, 0.135, 0.15, 0.86],
easeInBack: [0.6, -0.28, 0.735, 0.045],
easeOutBack: [0.175, 0.885, 0.32, 1.275],
easeInOutBack: [0.68, -0.55, 0.265, 1.55]
};
Chartist.Svg.Easing = easingCubicBeziers;
/**
* This helper class is to wrap multiple `Chartist.Svg` elements into a list where you can call the `Chartist.Svg` functions on all elements in the list with one call. This is helpful when you'd like to perform calls with `Chartist.Svg` on multiple elements.
* An instance of this class is also returned by `Chartist.Svg.querySelectorAll`.
*
* @memberof Chartist.Svg
* @param {Array
|NodeList} nodeList An Array of SVG DOM nodes or a SVG DOM NodeList (as returned by document.querySelectorAll)
* @constructor
*/
function SvgList(nodeList) {
var list = this;
this.svgElements = [];
for(var i = 0; i < nodeList.length; i++) {
this.svgElements.push(new Chartist.Svg(nodeList[i]));
}
// Add delegation methods for Chartist.Svg
Object.keys(Chartist.Svg.prototype).filter(function(prototypeProperty) {
return ['constructor',
'parent',
'querySelector',
'querySelectorAll',
'replace',
'append',
'classes',
'height',
'width'].indexOf(prototypeProperty) === -1;
}).forEach(function(prototypeProperty) {
list[prototypeProperty] = function() {
var args = Array.prototype.slice.call(arguments, 0);
list.svgElements.forEach(function(element) {
Chartist.Svg.prototype[prototypeProperty].apply(element, args);
});
return list;
};
});
}
Chartist.Svg.List = Chartist.Class.extend({
constructor: SvgList
});
}(window, document, Chartist));
;/**
* Chartist SVG path module for SVG path description creation and modification.
*
* @module Chartist.Svg.Path
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
/**
* Contains the descriptors of supported element types in a SVG path. Currently only move, line and curve are supported.
*
* @memberof Chartist.Svg.Path
* @type {Object}
*/
var elementDescriptions = {
m: ['x', 'y'],
l: ['x', 'y'],
c: ['x1', 'y1', 'x2', 'y2', 'x', 'y'],
a: ['rx', 'ry', 'xAr', 'lAf', 'sf', 'x', 'y']
};
/**
* Default options for newly created SVG path objects.
*
* @memberof Chartist.Svg.Path
* @type {Object}
*/
var defaultOptions = {
// The accuracy in digit count after the decimal point. This will be used to round numbers in the SVG path. If this option is set to false then no rounding will be performed.
accuracy: 3
};
function element(command, params, pathElements, pos, relative, data) {
var pathElement = Chartist.extend({
command: relative ? command.toLowerCase() : command.toUpperCase()
}, params, data ? { data: data } : {} );
pathElements.splice(pos, 0, pathElement);
}
function forEachParam(pathElements, cb) {
pathElements.forEach(function(pathElement, pathElementIndex) {
elementDescriptions[pathElement.command.toLowerCase()].forEach(function(paramName, paramIndex) {
cb(pathElement, paramName, pathElementIndex, paramIndex, pathElements);
});
});
}
/**
* Used to construct a new path object.
*
* @memberof Chartist.Svg.Path
* @param {Boolean} close If set to true then this path will be closed when stringified (with a Z at the end)
* @param {Object} options Options object that overrides the default objects. See default options for more details.
* @constructor
*/
function SvgPath(close, options) {
this.pathElements = [];
this.pos = 0;
this.close = close;
this.options = Chartist.extend({}, defaultOptions, options);
}
/**
* Gets or sets the current position (cursor) inside of the path. You can move around the cursor freely but limited to 0 or the count of existing elements. All modifications with element functions will insert new elements at the position of this cursor.
*
* @memberof Chartist.Svg.Path
* @param {Number} [pos] If a number is passed then the cursor is set to this position in the path element array.
* @return {Chartist.Svg.Path|Number} If the position parameter was passed then the return value will be the path object for easy call chaining. If no position parameter was passed then the current position is returned.
*/
function position(pos) {
if(pos !== undefined) {
this.pos = Math.max(0, Math.min(this.pathElements.length, pos));
return this;
} else {
return this.pos;
}
}
/**
* Removes elements from the path starting at the current position.
*
* @memberof Chartist.Svg.Path
* @param {Number} count Number of path elements that should be removed from the current position.
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function remove(count) {
this.pathElements.splice(this.pos, count);
return this;
}
/**
* Use this function to add a new move SVG path element.
*
* @memberof Chartist.Svg.Path
* @param {Number} x The x coordinate for the move element.
* @param {Number} y The y coordinate for the move element.
* @param {Boolean} [relative] If set to true the move element will be created with relative coordinates (lowercase letter)
* @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function move(x, y, relative, data) {
element('M', {
x: +x,
y: +y
}, this.pathElements, this.pos++, relative, data);
return this;
}
/**
* Use this function to add a new line SVG path element.
*
* @memberof Chartist.Svg.Path
* @param {Number} x The x coordinate for the line element.
* @param {Number} y The y coordinate for the line element.
* @param {Boolean} [relative] If set to true the line element will be created with relative coordinates (lowercase letter)
* @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function line(x, y, relative, data) {
element('L', {
x: +x,
y: +y
}, this.pathElements, this.pos++, relative, data);
return this;
}
/**
* Use this function to add a new curve SVG path element.
*
* @memberof Chartist.Svg.Path
* @param {Number} x1 The x coordinate for the first control point of the bezier curve.
* @param {Number} y1 The y coordinate for the first control point of the bezier curve.
* @param {Number} x2 The x coordinate for the second control point of the bezier curve.
* @param {Number} y2 The y coordinate for the second control point of the bezier curve.
* @param {Number} x The x coordinate for the target point of the curve element.
* @param {Number} y The y coordinate for the target point of the curve element.
* @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)
* @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function curve(x1, y1, x2, y2, x, y, relative, data) {
element('C', {
x1: +x1,
y1: +y1,
x2: +x2,
y2: +y2,
x: +x,
y: +y
}, this.pathElements, this.pos++, relative, data);
return this;
}
/**
* Use this function to add a new non-bezier curve SVG path element.
*
* @memberof Chartist.Svg.Path
* @param {Number} rx The radius to be used for the x-axis of the arc.
* @param {Number} ry The radius to be used for the y-axis of the arc.
* @param {Number} xAr Defines the orientation of the arc
* @param {Number} lAf Large arc flag
* @param {Number} sf Sweep flag
* @param {Number} x The x coordinate for the target point of the curve element.
* @param {Number} y The y coordinate for the target point of the curve element.
* @param {Boolean} [relative] If set to true the curve element will be created with relative coordinates (lowercase letter)
* @param {*} [data] Any data that should be stored with the element object that will be accessible in pathElement
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function arc(rx, ry, xAr, lAf, sf, x, y, relative, data) {
element('A', {
rx: +rx,
ry: +ry,
xAr: +xAr,
lAf: +lAf,
sf: +sf,
x: +x,
y: +y
}, this.pathElements, this.pos++, relative, data);
return this;
}
/**
* Parses an SVG path seen in the d attribute of path elements, and inserts the parsed elements into the existing path object at the current cursor position. Any closing path indicators (Z at the end of the path) will be ignored by the parser as this is provided by the close option in the options of the path object.
*
* @memberof Chartist.Svg.Path
* @param {String} path Any SVG path that contains move (m), line (l) or curve (c) components.
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function parse(path) {
// Parsing the SVG path string into an array of arrays [['M', '10', '10'], ['L', '100', '100']]
var chunks = path.replace(/([A-Za-z])([0-9])/g, '$1 $2')
.replace(/([0-9])([A-Za-z])/g, '$1 $2')
.split(/[\s,]+/)
.reduce(function(result, element) {
if(element.match(/[A-Za-z]/)) {
result.push([]);
}
result[result.length - 1].push(element);
return result;
}, []);
// If this is a closed path we remove the Z at the end because this is determined by the close option
if(chunks[chunks.length - 1][0].toUpperCase() === 'Z') {
chunks.pop();
}
// Using svgPathElementDescriptions to map raw path arrays into objects that contain the command and the parameters
// For example {command: 'M', x: '10', y: '10'}
var elements = chunks.map(function(chunk) {
var command = chunk.shift(),
description = elementDescriptions[command.toLowerCase()];
return Chartist.extend({
command: command
}, description.reduce(function(result, paramName, index) {
result[paramName] = +chunk[index];
return result;
}, {}));
});
// Preparing a splice call with the elements array as var arg params and insert the parsed elements at the current position
var spliceArgs = [this.pos, 0];
Array.prototype.push.apply(spliceArgs, elements);
Array.prototype.splice.apply(this.pathElements, spliceArgs);
// Increase the internal position by the element count
this.pos += elements.length;
return this;
}
/**
* This function renders to current SVG path object into a final SVG string that can be used in the d attribute of SVG path elements. It uses the accuracy option to round big decimals. If the close parameter was set in the constructor of this path object then a path closing Z will be appended to the output string.
*
* @memberof Chartist.Svg.Path
* @return {String}
*/
function stringify() {
var accuracyMultiplier = Math.pow(10, this.options.accuracy);
return this.pathElements.reduce(function(path, pathElement) {
var params = elementDescriptions[pathElement.command.toLowerCase()].map(function(paramName) {
return this.options.accuracy ?
(Math.round(pathElement[paramName] * accuracyMultiplier) / accuracyMultiplier) :
pathElement[paramName];
}.bind(this));
return path + pathElement.command + params.join(',');
}.bind(this), '') + (this.close ? 'Z' : '');
}
/**
* Scales all elements in the current SVG path object. There is an individual parameter for each coordinate. Scaling will also be done for control points of curves, affecting the given coordinate.
*
* @memberof Chartist.Svg.Path
* @param {Number} x The number which will be used to scale the x, x1 and x2 of all path elements.
* @param {Number} y The number which will be used to scale the y, y1 and y2 of all path elements.
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function scale(x, y) {
forEachParam(this.pathElements, function(pathElement, paramName) {
pathElement[paramName] *= paramName[0] === 'x' ? x : y;
});
return this;
}
/**
* Translates all elements in the current SVG path object. The translation is relative and there is an individual parameter for each coordinate. Translation will also be done for control points of curves, affecting the given coordinate.
*
* @memberof Chartist.Svg.Path
* @param {Number} x The number which will be used to translate the x, x1 and x2 of all path elements.
* @param {Number} y The number which will be used to translate the y, y1 and y2 of all path elements.
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function translate(x, y) {
forEachParam(this.pathElements, function(pathElement, paramName) {
pathElement[paramName] += paramName[0] === 'x' ? x : y;
});
return this;
}
/**
* This function will run over all existing path elements and then loop over their attributes. The callback function will be called for every path element attribute that exists in the current path.
* The method signature of the callback function looks like this:
* ```javascript
* function(pathElement, paramName, pathElementIndex, paramIndex, pathElements)
* ```
* If something else than undefined is returned by the callback function, this value will be used to replace the old value. This allows you to build custom transformations of path objects that can't be achieved using the basic transformation functions scale and translate.
*
* @memberof Chartist.Svg.Path
* @param {Function} transformFnc The callback function for the transformation. Check the signature in the function description.
* @return {Chartist.Svg.Path} The current path object for easy call chaining.
*/
function transform(transformFnc) {
forEachParam(this.pathElements, function(pathElement, paramName, pathElementIndex, paramIndex, pathElements) {
var transformed = transformFnc(pathElement, paramName, pathElementIndex, paramIndex, pathElements);
if(transformed || transformed === 0) {
pathElement[paramName] = transformed;
}
});
return this;
}
/**
* This function clones a whole path object with all its properties. This is a deep clone and path element objects will also be cloned.
*
* @memberof Chartist.Svg.Path
* @param {Boolean} [close] Optional option to set the new cloned path to closed. If not specified or false, the original path close option will be used.
* @return {Chartist.Svg.Path}
*/
function clone(close) {
var c = new Chartist.Svg.Path(close || this.close);
c.pos = this.pos;
c.pathElements = this.pathElements.slice().map(function cloneElements(pathElement) {
return Chartist.extend({}, pathElement);
});
c.options = Chartist.extend({}, this.options);
return c;
}
/**
* Split a Svg.Path object by a specific command in the path chain. The path chain will be split and an array of newly created paths objects will be returned. This is useful if you'd like to split an SVG path by it's move commands, for example, in order to isolate chunks of drawings.
*
* @memberof Chartist.Svg.Path
* @param {String} command The command you'd like to use to split the path
* @return {Array}
*/
function splitByCommand(command) {
var split = [
new Chartist.Svg.Path()
];
this.pathElements.forEach(function(pathElement) {
if(pathElement.command === command.toUpperCase() && split[split.length - 1].pathElements.length !== 0) {
split.push(new Chartist.Svg.Path());
}
split[split.length - 1].pathElements.push(pathElement);
});
return split;
}
/**
* This static function on `Chartist.Svg.Path` is joining multiple paths together into one paths.
*
* @memberof Chartist.Svg.Path
* @param {Array} paths A list of paths to be joined together. The order is important.
* @param {boolean} close If the newly created path should be a closed path
* @param {Object} options Path options for the newly created path.
* @return {Chartist.Svg.Path}
*/
function join(paths, close, options) {
var joinedPath = new Chartist.Svg.Path(close, options);
for(var i = 0; i < paths.length; i++) {
var path = paths[i];
for(var j = 0; j < path.pathElements.length; j++) {
joinedPath.pathElements.push(path.pathElements[j]);
}
}
return joinedPath;
}
Chartist.Svg.Path = Chartist.Class.extend({
constructor: SvgPath,
position: position,
remove: remove,
move: move,
line: line,
curve: curve,
arc: arc,
scale: scale,
translate: translate,
transform: transform,
parse: parse,
stringify: stringify,
clone: clone,
splitByCommand: splitByCommand
});
Chartist.Svg.Path.elementDescriptions = elementDescriptions;
Chartist.Svg.Path.join = join;
}(window, document, Chartist));
;/* global Chartist */
(function (window, document, Chartist) {
'use strict';
var axisUnits = {
x: {
pos: 'x',
len: 'width',
dir: 'horizontal',
rectStart: 'x1',
rectEnd: 'x2',
rectOffset: 'y2'
},
y: {
pos: 'y',
len: 'height',
dir: 'vertical',
rectStart: 'y2',
rectEnd: 'y1',
rectOffset: 'x1'
}
};
function Axis(units, chartRect, ticks, options) {
this.units = units;
this.counterUnits = units === axisUnits.x ? axisUnits.y : axisUnits.x;
this.chartRect = chartRect;
this.axisLength = chartRect[units.rectEnd] - chartRect[units.rectStart];
this.gridOffset = chartRect[units.rectOffset];
this.ticks = ticks;
this.options = options;
}
function createGridAndLabels(gridGroup, labelGroup, useForeignObject, chartOptions, eventEmitter) {
var axisOptions = chartOptions['axis' + this.units.pos.toUpperCase()];
var projectedValues = this.ticks.map(this.projectValue.bind(this));
var labelValues = this.ticks.map(axisOptions.labelInterpolationFnc);
projectedValues.forEach(function(projectedValue, index) {
var labelOffset = {
x: 0,
y: 0
};
// TODO: Find better solution for solving this problem
// Calculate how much space we have available for the label
var labelLength;
if(projectedValues[index + 1]) {
// If we still have one label ahead, we can calculate the distance to the next tick / label
labelLength = projectedValues[index + 1] - projectedValue;
} else {
// If we don't have a label ahead and we have only two labels in total, we just take the remaining distance to
// on the whole axis length. We limit that to a minimum of 30 pixel, so that labels close to the border will
// still be visible inside of the chart padding.
labelLength = Math.max(this.axisLength - projectedValue, 30);
}
// Skip grid lines and labels where interpolated label values are falsey (execpt for 0)
if(Chartist.isFalseyButZero(labelValues[index]) && labelValues[index] !== '') {
return;
}
// Transform to global coordinates using the chartRect
// We also need to set the label offset for the createLabel function
if(this.units.pos === 'x') {
projectedValue = this.chartRect.x1 + projectedValue;
labelOffset.x = chartOptions.axisX.labelOffset.x;
// If the labels should be positioned in start position (top side for vertical axis) we need to set a
// different offset as for positioned with end (bottom)
if(chartOptions.axisX.position === 'start') {
labelOffset.y = this.chartRect.padding.top + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);
} else {
labelOffset.y = this.chartRect.y1 + chartOptions.axisX.labelOffset.y + (useForeignObject ? 5 : 20);
}
} else {
projectedValue = this.chartRect.y1 - projectedValue;
labelOffset.y = chartOptions.axisY.labelOffset.y - (useForeignObject ? labelLength : 0);
// If the labels should be positioned in start position (left side for horizontal axis) we need to set a
// different offset as for positioned with end (right side)
if(chartOptions.axisY.position === 'start') {
labelOffset.x = useForeignObject ? this.chartRect.padding.left + chartOptions.axisY.labelOffset.x : this.chartRect.x1 - 10;
} else {
labelOffset.x = this.chartRect.x2 + chartOptions.axisY.labelOffset.x + 10;
}
}
if(axisOptions.showGrid) {
Chartist.createGrid(projectedValue, index, this, this.gridOffset, this.chartRect[this.counterUnits.len](), gridGroup, [
chartOptions.classNames.grid,
chartOptions.classNames[this.units.dir]
], eventEmitter);
}
if(axisOptions.showLabel) {
Chartist.createLabel(projectedValue, labelLength, index, labelValues, this, axisOptions.offset, labelOffset, labelGroup, [
chartOptions.classNames.label,
chartOptions.classNames[this.units.dir],
chartOptions.classNames[axisOptions.position]
], useForeignObject, eventEmitter);
}
}.bind(this));
}
Chartist.Axis = Chartist.Class.extend({
constructor: Axis,
createGridAndLabels: createGridAndLabels,
projectValue: function(value, index, data) {
throw new Error('Base axis can\'t be instantiated!');
}
});
Chartist.Axis.units = axisUnits;
}(window, document, Chartist));
;/**
* The auto scale axis uses standard linear scale projection of values along an axis. It uses order of magnitude to find a scale automatically and evaluates the available space in order to find the perfect amount of ticks for your chart.
* **Options**
* The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.
* ```javascript
* var options = {
* // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored
* high: 100,
* // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored
* low: 0,
* // This option will be used when finding the right scale division settings. The amount of ticks on the scale will be determined so that as many ticks as possible will be displayed, while not violating this minimum required space (in pixel).
* scaleMinSpace: 20,
* // Can be set to true or false. If set to true, the scale will be generated with whole numbers only.
* onlyInteger: true,
* // The reference value can be used to make sure that this value will always be on the chart. This is especially useful on bipolar charts where the bipolar center always needs to be part of the chart.
* referenceValue: 5
* };
* ```
*
* @module Chartist.AutoScaleAxis
*/
/* global Chartist */
(function (window, document, Chartist) {
'use strict';
function AutoScaleAxis(axisUnit, data, chartRect, options) {
// Usually we calculate highLow based on the data but this can be overriden by a highLow object in the options
var highLow = options.highLow || Chartist.getHighLow(data.normalized, options, axisUnit.pos);
this.bounds = Chartist.getBounds(chartRect[axisUnit.rectEnd] - chartRect[axisUnit.rectStart], highLow, options.scaleMinSpace || 20, options.onlyInteger);
this.range = {
min: this.bounds.min,
max: this.bounds.max
};
Chartist.AutoScaleAxis.super.constructor.call(this,
axisUnit,
chartRect,
this.bounds.values,
options);
}
function projectValue(value) {
return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.bounds.min) / this.bounds.range;
}
Chartist.AutoScaleAxis = Chartist.Axis.extend({
constructor: AutoScaleAxis,
projectValue: projectValue
});
}(window, document, Chartist));
;/**
* The fixed scale axis uses standard linear projection of values along an axis. It makes use of a divisor option to divide the range provided from the minimum and maximum value or the options high and low that will override the computed minimum and maximum.
* **Options**
* The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.
* ```javascript
* var options = {
* // If high is specified then the axis will display values explicitly up to this value and the computed maximum from the data is ignored
* high: 100,
* // If low is specified then the axis will display values explicitly down to this value and the computed minimum from the data is ignored
* low: 0,
* // If specified then the value range determined from minimum to maximum (or low and high) will be divided by this number and ticks will be generated at those division points. The default divisor is 1.
* divisor: 4,
* // If ticks is explicitly set, then the axis will not compute the ticks with the divisor, but directly use the data in ticks to determine at what points on the axis a tick need to be generated.
* ticks: [1, 10, 20, 30]
* };
* ```
*
* @module Chartist.FixedScaleAxis
*/
/* global Chartist */
(function (window, document, Chartist) {
'use strict';
function FixedScaleAxis(axisUnit, data, chartRect, options) {
var highLow = options.highLow || Chartist.getHighLow(data.normalized, options, axisUnit.pos);
this.divisor = options.divisor || 1;
this.ticks = options.ticks || Chartist.times(this.divisor).map(function(value, index) {
return highLow.low + (highLow.high - highLow.low) / this.divisor * index;
}.bind(this));
this.ticks.sort(function(a, b) {
return a - b;
});
this.range = {
min: highLow.low,
max: highLow.high
};
Chartist.FixedScaleAxis.super.constructor.call(this,
axisUnit,
chartRect,
this.ticks,
options);
this.stepLength = this.axisLength / this.divisor;
}
function projectValue(value) {
return this.axisLength * (+Chartist.getMultiValue(value, this.units.pos) - this.range.min) / (this.range.max - this.range.min);
}
Chartist.FixedScaleAxis = Chartist.Axis.extend({
constructor: FixedScaleAxis,
projectValue: projectValue
});
}(window, document, Chartist));
;/**
* The step axis for step based charts like bar chart or step based line charts. It uses a fixed amount of ticks that will be equally distributed across the whole axis length. The projection is done using the index of the data value rather than the value itself and therefore it's only useful for distribution purpose.
* **Options**
* The following options are used by this axis in addition to the default axis options outlined in the axis configuration of the chart default settings.
* ```javascript
* var options = {
* // Ticks to be used to distribute across the axis length. As this axis type relies on the index of the value rather than the value, arbitrary data that can be converted to a string can be used as ticks.
* ticks: ['One', 'Two', 'Three'],
* // If set to true the full width will be used to distribute the values where the last value will be at the maximum of the axis length. If false the spaces between the ticks will be evenly distributed instead.
* stretch: true
* };
* ```
*
* @module Chartist.StepAxis
*/
/* global Chartist */
(function (window, document, Chartist) {
'use strict';
function StepAxis(axisUnit, data, chartRect, options) {
Chartist.StepAxis.super.constructor.call(this,
axisUnit,
chartRect,
options.ticks,
options);
this.stepLength = this.axisLength / (options.ticks.length - (options.stretch ? 1 : 0));
}
function projectValue(value, index) {
return this.stepLength * index;
}
Chartist.StepAxis = Chartist.Axis.extend({
constructor: StepAxis,
projectValue: projectValue
});
}(window, document, Chartist));
;/**
* The Chartist line chart can be used to draw Line or Scatter charts. If used in the browser you can access the global `Chartist` namespace where you find the `Line` function as a main entry point.
*
* For examples on how to use the line chart please check the examples of the `Chartist.Line` method.
*
* @module Chartist.Line
*/
/* global Chartist */
(function(window, document, Chartist){
'use strict';
/**
* Default options in line charts. Expand the code view to see a detailed list of options with comments.
*
* @memberof Chartist.Line
*/
var defaultOptions = {
// Options for X-Axis
axisX: {
// The offset of the labels to the chart area
offset: 30,
// Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
position: 'end',
// Allows you to correct label positioning on this axis by positive or negative x and y offset.
labelOffset: {
x: 0,
y: 0
},
// If labels should be shown or not
showLabel: true,
// If the axis grid should be drawn or not
showGrid: true,
// Interpolation function that allows you to intercept the value from the axis label
labelInterpolationFnc: Chartist.noop,
// Set the axis type to be used to project values on this axis. If not defined, Chartist.StepAxis will be used for the X-Axis, where the ticks option will be set to the labels in the data and the stretch option will be set to the global fullWidth option. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.
type: undefined
},
// Options for Y-Axis
axisY: {
// The offset of the labels to the chart area
offset: 40,
// Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
position: 'start',
// Allows you to correct label positioning on this axis by positive or negative x and y offset.
labelOffset: {
x: 0,
y: 0
},
// If labels should be shown or not
showLabel: true,
// If the axis grid should be drawn or not
showGrid: true,
// Interpolation function that allows you to intercept the value from the axis label
labelInterpolationFnc: Chartist.noop,
// Set the axis type to be used to project values on this axis. If not defined, Chartist.AutoScaleAxis will be used for the Y-Axis, where the high and low options will be set to the global high and low options. This type can be changed to any axis constructor available (e.g. Chartist.FixedScaleAxis), where all axis options should be present here.
type: undefined,
// This value specifies the minimum height in pixel of the scale steps
scaleMinSpace: 20,
// Use only integer values (whole numbers) for the scale steps
onlyInteger: false
},
// Specify a fixed width for the chart as a string (i.e. '100px' or '50%')
width: undefined,
// Specify a fixed height for the chart as a string (i.e. '100px' or '50%')
height: undefined,
// If the line should be drawn or not
showLine: true,
// If dots should be drawn or not
showPoint: true,
// If the line chart should draw an area
showArea: false,
// The base for the area chart that will be used to close the area shape (is normally 0)
areaBase: 0,
// Specify if the lines should be smoothed. This value can be true or false where true will result in smoothing using the default smoothing interpolation function Chartist.Interpolation.cardinal and false results in Chartist.Interpolation.none. You can also choose other smoothing / interpolation functions available in the Chartist.Interpolation module, or write your own interpolation function. Check the examples for a brief description.
lineSmooth: true,
// Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value
low: undefined,
// Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value
high: undefined,
// Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}
chartPadding: {
top: 15,
right: 15,
bottom: 5,
left: 10
},
// When set to true, the last grid line on the x-axis is not drawn and the chart elements will expand to the full available width of the chart. For the last label to be drawn correctly you might need to add chart padding or offset the last label with a draw event handler.
fullWidth: false,
// If true the whole data is reversed including labels, the series order as well as the whole series data arrays.
reverseData: false,
// Override the class names that get used to generate the SVG structure of the chart
classNames: {
chart: 'ct-chart-line',
label: 'ct-label',
labelGroup: 'ct-labels',
series: 'ct-series',
line: 'ct-line',
point: 'ct-point',
area: 'ct-area',
grid: 'ct-grid',
gridGroup: 'ct-grids',
vertical: 'ct-vertical',
horizontal: 'ct-horizontal',
start: 'ct-start',
end: 'ct-end'
}
};
/**
* Creates a new chart
*
*/
function createChart(options) {
this.data = Chartist.normalizeData(this.data);
var data = {
raw: this.data,
normalized: Chartist.getDataArray(this.data, options.reverseData, true)
};
// Create new svg object
this.svg = Chartist.createSvg(this.container, options.width, options.height, options.classNames.chart);
// Create groups for labels, grid and series
var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);
var seriesGroup = this.svg.elem('g');
var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);
var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);
var axisX, axisY;
if(options.axisX.type === undefined) {
axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {
ticks: data.raw.labels,
stretch: options.fullWidth
}));
} else {
axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, options.axisX);
}
if(options.axisY.type === undefined) {
axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {
high: Chartist.isNum(options.high) ? options.high : options.axisY.high,
low: Chartist.isNum(options.low) ? options.low : options.axisY.low
}));
} else {
axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, options.axisY);
}
axisX.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
axisY.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
// Draw the series
data.raw.series.forEach(function(series, seriesIndex) {
var seriesElement = seriesGroup.elem('g');
// Write attributes to series group element. If series name or meta is undefined the attributes will not be written
seriesElement.attr({
'ct:series-name': series.name,
'ct:meta': Chartist.serialize(series.meta)
});
// Use series class from series data or if not set generate one
seriesElement.addClass([
options.classNames.series,
(series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))
].join(' '));
var pathCoordinates = [],
pathData = [];
data.normalized[seriesIndex].forEach(function(value, valueIndex) {
var p = {
x: chartRect.x1 + axisX.projectValue(value, valueIndex, data.normalized[seriesIndex]),
y: chartRect.y1 - axisY.projectValue(value, valueIndex, data.normalized[seriesIndex])
};
pathCoordinates.push(p.x, p.y);
pathData.push({
value: value,
valueIndex: valueIndex,
meta: Chartist.getMetaData(series, valueIndex)
});
}.bind(this));
var seriesOptions = {
lineSmooth: Chartist.getSeriesOption(series, options, 'lineSmooth'),
showPoint: Chartist.getSeriesOption(series, options, 'showPoint'),
showLine: Chartist.getSeriesOption(series, options, 'showLine'),
showArea: Chartist.getSeriesOption(series, options, 'showArea'),
areaBase: Chartist.getSeriesOption(series, options, 'areaBase')
};
var smoothing = typeof seriesOptions.lineSmooth === 'function' ?
seriesOptions.lineSmooth : (seriesOptions.lineSmooth ? Chartist.Interpolation.monotoneCubic() : Chartist.Interpolation.none());
// Interpolating path where pathData will be used to annotate each path element so we can trace back the original
// index, value and meta data
var path = smoothing(pathCoordinates, pathData);
// If we should show points we need to create them now to avoid secondary loop
// Points are drawn from the pathElements returned by the interpolation function
// Small offset for Firefox to render squares correctly
if (seriesOptions.showPoint) {
path.pathElements.forEach(function(pathElement) {
var point = seriesElement.elem('line', {
x1: pathElement.x,
y1: pathElement.y,
x2: pathElement.x + 0.01,
y2: pathElement.y
}, options.classNames.point).attr({
'ct:value': [pathElement.data.value.x, pathElement.data.value.y].filter(Chartist.isNum).join(','),
'ct:meta': pathElement.data.meta
});
this.eventEmitter.emit('draw', {
type: 'point',
value: pathElement.data.value,
index: pathElement.data.valueIndex,
meta: pathElement.data.meta,
series: series,
seriesIndex: seriesIndex,
axisX: axisX,
axisY: axisY,
group: seriesElement,
element: point,
x: pathElement.x,
y: pathElement.y
});
}.bind(this));
}
if(seriesOptions.showLine) {
var line = seriesElement.elem('path', {
d: path.stringify()
}, options.classNames.line, true);
this.eventEmitter.emit('draw', {
type: 'line',
values: data.normalized[seriesIndex],
path: path.clone(),
chartRect: chartRect,
index: seriesIndex,
series: series,
seriesIndex: seriesIndex,
axisX: axisX,
axisY: axisY,
group: seriesElement,
element: line
});
}
// Area currently only works with axes that support a range!
if(seriesOptions.showArea && axisY.range) {
// If areaBase is outside the chart area (< min or > max) we need to set it respectively so that
// the area is not drawn outside the chart area.
var areaBase = Math.max(Math.min(seriesOptions.areaBase, axisY.range.max), axisY.range.min);
// We project the areaBase value into screen coordinates
var areaBaseProjected = chartRect.y1 - axisY.projectValue(areaBase);
// In order to form the area we'll first split the path by move commands so we can chunk it up into segments
path.splitByCommand('M').filter(function onlySolidSegments(pathSegment) {
// We filter only "solid" segments that contain more than one point. Otherwise there's no need for an area
return pathSegment.pathElements.length > 1;
}).map(function convertToArea(solidPathSegments) {
// Receiving the filtered solid path segments we can now convert those segments into fill areas
var firstElement = solidPathSegments.pathElements[0];
var lastElement = solidPathSegments.pathElements[solidPathSegments.pathElements.length - 1];
// Cloning the solid path segment with closing option and removing the first move command from the clone
// We then insert a new move that should start at the area base and draw a straight line up or down
// at the end of the path we add an additional straight line to the projected area base value
// As the closing option is set our path will be automatically closed
return solidPathSegments.clone(true)
.position(0)
.remove(1)
.move(firstElement.x, areaBaseProjected)
.line(firstElement.x, firstElement.y)
.position(solidPathSegments.pathElements.length + 1)
.line(lastElement.x, areaBaseProjected);
}).forEach(function createArea(areaPath) {
// For each of our newly created area paths, we'll now create path elements by stringifying our path objects
// and adding the created DOM elements to the correct series group
var area = seriesElement.elem('path', {
d: areaPath.stringify()
}, options.classNames.area, true);
// Emit an event for each area that was drawn
this.eventEmitter.emit('draw', {
type: 'area',
values: data.normalized[seriesIndex],
path: areaPath.clone(),
series: series,
seriesIndex: seriesIndex,
axisX: axisX,
axisY: axisY,
chartRect: chartRect,
index: seriesIndex,
group: seriesElement,
element: area
});
}.bind(this));
}
}.bind(this));
this.eventEmitter.emit('created', {
bounds: axisY.bounds,
chartRect: chartRect,
axisX: axisX,
axisY: axisY,
svg: this.svg,
options: options
});
}
/**
* This method creates a new line chart.
*
* @memberof Chartist.Line
* @param {String|Node} query A selector query string or directly a DOM element
* @param {Object} data The data object that needs to consist of a labels and a series array
* @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.
* @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]
* @return {Object} An object which exposes the API for the created chart
*
* @example
* // Create a simple line chart
* var data = {
* // A labels array that can contain any sort of values
* labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
* // Our series array that contains series objects or in this case series data arrays
* series: [
* [5, 2, 4, 2, 0]
* ]
* };
*
* // As options we currently only set a static size of 300x200 px
* var options = {
* width: '300px',
* height: '200px'
* };
*
* // In the global name space Chartist we call the Line function to initialize a line chart. As a first parameter we pass in a selector where we would like to get our chart created. Second parameter is the actual data object and as a third parameter we pass in our options
* new Chartist.Line('.ct-chart', data, options);
*
* @example
* // Use specific interpolation function with configuration from the Chartist.Interpolation module
*
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [
* [1, 1, 8, 1, 7]
* ]
* }, {
* lineSmooth: Chartist.Interpolation.cardinal({
* tension: 0.2
* })
* });
*
* @example
* // Create a line chart with responsive options
*
* var data = {
* // A labels array that can contain any sort of values
* labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
* // Our series array that contains series objects or in this case series data arrays
* series: [
* [5, 2, 4, 2, 0]
* ]
* };
*
* // In addition to the regular options we specify responsive option overrides that will override the default configutation based on the matching media queries.
* var responsiveOptions = [
* ['screen and (min-width: 641px) and (max-width: 1024px)', {
* showPoint: false,
* axisX: {
* labelInterpolationFnc: function(value) {
* // Will return Mon, Tue, Wed etc. on medium screens
* return value.slice(0, 3);
* }
* }
* }],
* ['screen and (max-width: 640px)', {
* showLine: false,
* axisX: {
* labelInterpolationFnc: function(value) {
* // Will return M, T, W etc. on small screens
* return value[0];
* }
* }
* }]
* ];
*
* new Chartist.Line('.ct-chart', data, null, responsiveOptions);
*
*/
function Line(query, data, options, responsiveOptions) {
Chartist.Line.super.constructor.call(this,
query,
data,
defaultOptions,
Chartist.extend({}, defaultOptions, options),
responsiveOptions);
}
// Creating line chart type in Chartist namespace
Chartist.Line = Chartist.Base.extend({
constructor: Line,
createChart: createChart
});
}(window, document, Chartist));
;/**
* The bar chart module of Chartist that can be used to draw unipolar or bipolar bar and grouped bar charts.
*
* @module Chartist.Bar
*/
/* global Chartist */
(function(window, document, Chartist){
'use strict';
/**
* Default options in bar charts. Expand the code view to see a detailed list of options with comments.
*
* @memberof Chartist.Bar
*/
var defaultOptions = {
// Options for X-Axis
axisX: {
// The offset of the chart drawing area to the border of the container
offset: 30,
// Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
position: 'end',
// Allows you to correct label positioning on this axis by positive or negative x and y offset.
labelOffset: {
x: 0,
y: 0
},
// If labels should be shown or not
showLabel: true,
// If the axis grid should be drawn or not
showGrid: true,
// Interpolation function that allows you to intercept the value from the axis label
labelInterpolationFnc: Chartist.noop,
// This value specifies the minimum width in pixel of the scale steps
scaleMinSpace: 30,
// Use only integer values (whole numbers) for the scale steps
onlyInteger: false
},
// Options for Y-Axis
axisY: {
// The offset of the chart drawing area to the border of the container
offset: 40,
// Position where labels are placed. Can be set to `start` or `end` where `start` is equivalent to left or top on vertical axis and `end` is equivalent to right or bottom on horizontal axis.
position: 'start',
// Allows you to correct label positioning on this axis by positive or negative x and y offset.
labelOffset: {
x: 0,
y: 0
},
// If labels should be shown or not
showLabel: true,
// If the axis grid should be drawn or not
showGrid: true,
// Interpolation function that allows you to intercept the value from the axis label
labelInterpolationFnc: Chartist.noop,
// This value specifies the minimum height in pixel of the scale steps
scaleMinSpace: 20,
// Use only integer values (whole numbers) for the scale steps
onlyInteger: false
},
// Specify a fixed width for the chart as a string (i.e. '100px' or '50%')
width: undefined,
// Specify a fixed height for the chart as a string (i.e. '100px' or '50%')
height: undefined,
// Overriding the natural high of the chart allows you to zoom in or limit the charts highest displayed value
high: undefined,
// Overriding the natural low of the chart allows you to zoom in or limit the charts lowest displayed value
low: undefined,
// Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}
chartPadding: {
top: 15,
right: 15,
bottom: 5,
left: 10
},
// Specify the distance in pixel of bars in a group
seriesBarDistance: 15,
// If set to true this property will cause the series bars to be stacked. Check the `stackMode` option for further stacking options.
stackBars: false,
// If set to 'overlap' this property will force the stacked bars to draw from the zero line.
// If set to 'accumulate' this property will form a total for each series point. This will also influence the y-axis and the overall bounds of the chart. In stacked mode the seriesBarDistance property will have no effect.
stackMode: 'accumulate',
// Inverts the axes of the bar chart in order to draw a horizontal bar chart. Be aware that you also need to invert your axis settings as the Y Axis will now display the labels and the X Axis the values.
horizontalBars: false,
// If set to true then each bar will represent a series and the data array is expected to be a one dimensional array of data values rather than a series array of series. This is useful if the bar chart should represent a profile rather than some data over time.
distributeSeries: false,
// If true the whole data is reversed including labels, the series order as well as the whole series data arrays.
reverseData: false,
// Override the class names that get used to generate the SVG structure of the chart
classNames: {
chart: 'ct-chart-bar',
horizontalBars: 'ct-horizontal-bars',
label: 'ct-label',
labelGroup: 'ct-labels',
series: 'ct-series',
bar: 'ct-bar',
grid: 'ct-grid',
gridGroup: 'ct-grids',
vertical: 'ct-vertical',
horizontal: 'ct-horizontal',
start: 'ct-start',
end: 'ct-end'
}
};
/**
* Creates a new chart
*
*/
function createChart(options) {
this.data = Chartist.normalizeData(this.data);
var data = {
raw: this.data,
normalized: options.distributeSeries ? Chartist.getDataArray(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y').map(function(value) {
return [value];
}) : Chartist.getDataArray(this.data, options.reverseData, options.horizontalBars ? 'x' : 'y')
};
var highLow;
// Create new svg element
this.svg = Chartist.createSvg(
this.container,
options.width,
options.height,
options.classNames.chart + (options.horizontalBars ? ' ' + options.classNames.horizontalBars : '')
);
// Drawing groups in correct order
var gridGroup = this.svg.elem('g').addClass(options.classNames.gridGroup);
var seriesGroup = this.svg.elem('g');
var labelGroup = this.svg.elem('g').addClass(options.classNames.labelGroup);
if(options.stackBars && data.normalized.length !== 0) {
// If stacked bars we need to calculate the high low from stacked values from each series
var serialSums = Chartist.serialMap(data.normalized, function serialSums() {
return Array.prototype.slice.call(arguments).map(function(value) {
return value;
}).reduce(function(prev, curr) {
return {
x: prev.x + (curr && curr.x) || 0,
y: prev.y + (curr && curr.y) || 0
};
}, {x: 0, y: 0});
});
highLow = Chartist.getHighLow([serialSums], Chartist.extend({}, options, {
referenceValue: 0
}), options.horizontalBars ? 'x' : 'y');
} else {
highLow = Chartist.getHighLow(data.normalized, Chartist.extend({}, options, {
referenceValue: 0
}), options.horizontalBars ? 'x' : 'y');
}
// Overrides of high / low from settings
highLow.high = +options.high || (options.high === 0 ? 0 : highLow.high);
highLow.low = +options.low || (options.low === 0 ? 0 : highLow.low);
var chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);
var valueAxis,
labelAxisTicks,
labelAxis,
axisX,
axisY;
// We need to set step count based on some options combinations
if(options.distributeSeries && options.stackBars) {
// If distributed series are enabled and bars need to be stacked, we'll only have one bar and therefore should
// use only the first label for the step axis
labelAxisTicks = data.raw.labels.slice(0, 1);
} else {
// If distributed series are enabled but stacked bars aren't, we should use the series labels
// If we are drawing a regular bar chart with two dimensional series data, we just use the labels array
// as the bars are normalized
labelAxisTicks = data.raw.labels;
}
// Set labelAxis and valueAxis based on the horizontalBars setting. This setting will flip the axes if necessary.
if(options.horizontalBars) {
if(options.axisX.type === undefined) {
valueAxis = axisX = new Chartist.AutoScaleAxis(Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {
highLow: highLow,
referenceValue: 0
}));
} else {
valueAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, Chartist.extend({}, options.axisX, {
highLow: highLow,
referenceValue: 0
}));
}
if(options.axisY.type === undefined) {
labelAxis = axisY = new Chartist.StepAxis(Chartist.Axis.units.y, data, chartRect, {
ticks: labelAxisTicks
});
} else {
labelAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, options.axisY);
}
} else {
if(options.axisX.type === undefined) {
labelAxis = axisX = new Chartist.StepAxis(Chartist.Axis.units.x, data, chartRect, {
ticks: labelAxisTicks
});
} else {
labelAxis = axisX = options.axisX.type.call(Chartist, Chartist.Axis.units.x, data, chartRect, options.axisX);
}
if(options.axisY.type === undefined) {
valueAxis = axisY = new Chartist.AutoScaleAxis(Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {
highLow: highLow,
referenceValue: 0
}));
} else {
valueAxis = axisY = options.axisY.type.call(Chartist, Chartist.Axis.units.y, data, chartRect, Chartist.extend({}, options.axisY, {
highLow: highLow,
referenceValue: 0
}));
}
}
// Projected 0 point
var zeroPoint = options.horizontalBars ? (chartRect.x1 + valueAxis.projectValue(0)) : (chartRect.y1 - valueAxis.projectValue(0));
// Used to track the screen coordinates of stacked bars
var stackedBarValues = [];
labelAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
valueAxis.createGridAndLabels(gridGroup, labelGroup, this.supportsForeignObject, options, this.eventEmitter);
// Draw the series
data.raw.series.forEach(function(series, seriesIndex) {
// Calculating bi-polar value of index for seriesOffset. For i = 0..4 biPol will be -1.5, -0.5, 0.5, 1.5 etc.
var biPol = seriesIndex - (data.raw.series.length - 1) / 2;
// Half of the period width between vertical grid lines used to position bars
var periodHalfLength;
// Current series SVG element
var seriesElement;
// We need to set periodHalfLength based on some options combinations
if(options.distributeSeries && !options.stackBars) {
// If distributed series are enabled but stacked bars aren't, we need to use the length of the normaizedData array
// which is the series count and divide by 2
periodHalfLength = labelAxis.axisLength / data.normalized.length / 2;
} else if(options.distributeSeries && options.stackBars) {
// If distributed series and stacked bars are enabled we'll only get one bar so we should just divide the axis
// length by 2
periodHalfLength = labelAxis.axisLength / 2;
} else {
// On regular bar charts we should just use the series length
periodHalfLength = labelAxis.axisLength / data.normalized[seriesIndex].length / 2;
}
// Adding the series group to the series element
seriesElement = seriesGroup.elem('g');
// Write attributes to series group element. If series name or meta is undefined the attributes will not be written
seriesElement.attr({
'ct:series-name': series.name,
'ct:meta': Chartist.serialize(series.meta)
});
// Use series class from series data or if not set generate one
seriesElement.addClass([
options.classNames.series,
(series.className || options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex))
].join(' '));
data.normalized[seriesIndex].forEach(function(value, valueIndex) {
var projected,
bar,
previousStack,
labelAxisValueIndex;
// We need to set labelAxisValueIndex based on some options combinations
if(options.distributeSeries && !options.stackBars) {
// If distributed series are enabled but stacked bars aren't, we can use the seriesIndex for later projection
// on the step axis for label positioning
labelAxisValueIndex = seriesIndex;
} else if(options.distributeSeries && options.stackBars) {
// If distributed series and stacked bars are enabled, we will only get one bar and therefore always use
// 0 for projection on the label step axis
labelAxisValueIndex = 0;
} else {
// On regular bar charts we just use the value index to project on the label step axis
labelAxisValueIndex = valueIndex;
}
// We need to transform coordinates differently based on the chart layout
if(options.horizontalBars) {
projected = {
x: chartRect.x1 + valueAxis.projectValue(value && value.x ? value.x : 0, valueIndex, data.normalized[seriesIndex]),
y: chartRect.y1 - labelAxis.projectValue(value && value.y ? value.y : 0, labelAxisValueIndex, data.normalized[seriesIndex])
};
} else {
projected = {
x: chartRect.x1 + labelAxis.projectValue(value && value.x ? value.x : 0, labelAxisValueIndex, data.normalized[seriesIndex]),
y: chartRect.y1 - valueAxis.projectValue(value && value.y ? value.y : 0, valueIndex, data.normalized[seriesIndex])
}
}
// If the label axis is a step based axis we will offset the bar into the middle of between two steps using
// the periodHalfLength value. Also we do arrange the different series so that they align up to each other using
// the seriesBarDistance. If we don't have a step axis, the bar positions can be chosen freely so we should not
// add any automated positioning.
if(labelAxis instanceof Chartist.StepAxis) {
// Offset to center bar between grid lines, but only if the step axis is not stretched
if(!labelAxis.options.stretch) {
projected[labelAxis.units.pos] += periodHalfLength * (options.horizontalBars ? -1 : 1);
}
// Using bi-polar offset for multiple series if no stacked bars or series distribution is used
projected[labelAxis.units.pos] += (options.stackBars || options.distributeSeries) ? 0 : biPol * options.seriesBarDistance * (options.horizontalBars ? -1 : 1);
}
// Enter value in stacked bar values used to remember previous screen value for stacking up bars
previousStack = stackedBarValues[valueIndex] || zeroPoint;
stackedBarValues[valueIndex] = previousStack - (zeroPoint - projected[labelAxis.counterUnits.pos]);
// Skip if value is undefined
if(value === undefined) {
return;
}
var positions = {};
positions[labelAxis.units.pos + '1'] = projected[labelAxis.units.pos];
positions[labelAxis.units.pos + '2'] = projected[labelAxis.units.pos];
if(options.stackBars && (options.stackMode === 'accumulate' || !options.stackMode)) {
// Stack mode: accumulate (default)
// If bars are stacked we use the stackedBarValues reference and otherwise base all bars off the zero line
// We want backwards compatibility, so the expected fallback without the 'stackMode' option
// to be the original behaviour (accumulate)
positions[labelAxis.counterUnits.pos + '1'] = previousStack;
positions[labelAxis.counterUnits.pos + '2'] = stackedBarValues[valueIndex];
} else {
// Draw from the zero line normally
// This is also the same code for Stack mode: overlap
positions[labelAxis.counterUnits.pos + '1'] = zeroPoint;
positions[labelAxis.counterUnits.pos + '2'] = projected[labelAxis.counterUnits.pos];
}
// Limit x and y so that they are within the chart rect
positions.x1 = Math.min(Math.max(positions.x1, chartRect.x1), chartRect.x2);
positions.x2 = Math.min(Math.max(positions.x2, chartRect.x1), chartRect.x2);
positions.y1 = Math.min(Math.max(positions.y1, chartRect.y2), chartRect.y1);
positions.y2 = Math.min(Math.max(positions.y2, chartRect.y2), chartRect.y1);
// Create bar element
bar = seriesElement.elem('line', positions, options.classNames.bar).attr({
'ct:value': [value.x, value.y].filter(Chartist.isNum).join(','),
'ct:meta': Chartist.getMetaData(series, valueIndex)
});
this.eventEmitter.emit('draw', Chartist.extend({
type: 'bar',
value: value,
index: valueIndex,
meta: Chartist.getMetaData(series, valueIndex),
series: series,
seriesIndex: seriesIndex,
axisX: axisX,
axisY: axisY,
chartRect: chartRect,
group: seriesElement,
element: bar
}, positions));
}.bind(this));
}.bind(this));
this.eventEmitter.emit('created', {
bounds: valueAxis.bounds,
chartRect: chartRect,
axisX: axisX,
axisY: axisY,
svg: this.svg,
options: options
});
}
/**
* This method creates a new bar chart and returns API object that you can use for later changes.
*
* @memberof Chartist.Bar
* @param {String|Node} query A selector query string or directly a DOM element
* @param {Object} data The data object that needs to consist of a labels and a series array
* @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.
* @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]
* @return {Object} An object which exposes the API for the created chart
*
* @example
* // Create a simple bar chart
* var data = {
* labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
* series: [
* [5, 2, 4, 2, 0]
* ]
* };
*
* // In the global name space Chartist we call the Bar function to initialize a bar chart. As a first parameter we pass in a selector where we would like to get our chart created and as a second parameter we pass our data object.
* new Chartist.Bar('.ct-chart', data);
*
* @example
* // This example creates a bipolar grouped bar chart where the boundaries are limitted to -10 and 10
* new Chartist.Bar('.ct-chart', {
* labels: [1, 2, 3, 4, 5, 6, 7],
* series: [
* [1, 3, 2, -5, -3, 1, -6],
* [-5, -2, -4, -1, 2, -3, 1]
* ]
* }, {
* seriesBarDistance: 12,
* low: -10,
* high: 10
* });
*
*/
function Bar(query, data, options, responsiveOptions) {
Chartist.Bar.super.constructor.call(this,
query,
data,
defaultOptions,
Chartist.extend({}, defaultOptions, options),
responsiveOptions);
}
// Creating bar chart type in Chartist namespace
Chartist.Bar = Chartist.Base.extend({
constructor: Bar,
createChart: createChart
});
}(window, document, Chartist));
;/**
* The pie chart module of Chartist that can be used to draw pie, donut or gauge charts
*
* @module Chartist.Pie
*/
/* global Chartist */
(function(window, document, Chartist) {
'use strict';
/**
* Default options in line charts. Expand the code view to see a detailed list of options with comments.
*
* @memberof Chartist.Pie
*/
var defaultOptions = {
// Specify a fixed width for the chart as a string (i.e. '100px' or '50%')
width: undefined,
// Specify a fixed height for the chart as a string (i.e. '100px' or '50%')
height: undefined,
// Padding of the chart drawing area to the container element and labels as a number or padding object {top: 5, right: 5, bottom: 5, left: 5}
chartPadding: 5,
// Override the class names that are used to generate the SVG structure of the chart
classNames: {
chartPie: 'ct-chart-pie',
chartDonut: 'ct-chart-donut',
series: 'ct-series',
slicePie: 'ct-slice-pie',
sliceDonut: 'ct-slice-donut',
label: 'ct-label'
},
// The start angle of the pie chart in degrees where 0 points north. A higher value offsets the start angle clockwise.
startAngle: 0,
// An optional total you can specify. By specifying a total value, the sum of the values in the series must be this total in order to draw a full pie. You can use this parameter to draw only parts of a pie or gauge charts.
total: undefined,
// If specified the donut CSS classes will be used and strokes will be drawn instead of pie slices.
donut: false,
// Specify the donut stroke width, currently done in javascript for convenience. May move to CSS styles in the future.
// This option can be set as number or string to specify a relative width (i.e. 100 or '30%').
donutWidth: 60,
// If a label should be shown or not
showLabel: true,
// Label position offset from the standard position which is half distance of the radius. This value can be either positive or negative. Positive values will position the label away from the center.
labelOffset: 0,
// This option can be set to 'inside', 'outside' or 'center'. Positioned with 'inside' the labels will be placed on half the distance of the radius to the border of the Pie by respecting the 'labelOffset'. The 'outside' option will place the labels at the border of the pie and 'center' will place the labels in the absolute center point of the chart. The 'center' option only makes sense in conjunction with the 'labelOffset' option.
labelPosition: 'inside',
// An interpolation function for the label value
labelInterpolationFnc: Chartist.noop,
// Label direction can be 'neutral', 'explode' or 'implode'. The labels anchor will be positioned based on those settings as well as the fact if the labels are on the right or left side of the center of the chart. Usually explode is useful when labels are positioned far away from the center.
labelDirection: 'neutral',
// If true the whole data is reversed including labels, the series order as well as the whole series data arrays.
reverseData: false,
// If true empty values will be ignored to avoid drawing unncessary slices and labels
ignoreEmptyValues: false
};
/**
* Determines SVG anchor position based on direction and center parameter
*
* @param center
* @param label
* @param direction
* @return {string}
*/
function determineAnchorPosition(center, label, direction) {
var toTheRight = label.x > center.x;
if(toTheRight && direction === 'explode' ||
!toTheRight && direction === 'implode') {
return 'start';
} else if(toTheRight && direction === 'implode' ||
!toTheRight && direction === 'explode') {
return 'end';
} else {
return 'middle';
}
}
/**
* Creates the pie chart
*
* @param options
*/
function createChart(options) {
this.data = Chartist.normalizeData(this.data);
var seriesGroups = [],
labelsGroup,
chartRect,
radius,
labelRadius,
totalDataSum,
startAngle = options.startAngle,
dataArray = Chartist.getDataArray(this.data, options.reverseData);
// Create SVG.js draw
this.svg = Chartist.createSvg(this.container, options.width, options.height,options.donut ? options.classNames.chartDonut : options.classNames.chartPie);
// Calculate charting rect
chartRect = Chartist.createChartRect(this.svg, options, defaultOptions.padding);
// Get biggest circle radius possible within chartRect
radius = Math.min(chartRect.width() / 2, chartRect.height() / 2);
// Calculate total of all series to get reference value or use total reference from optional options
totalDataSum = options.total || dataArray.reduce(function(previousValue, currentValue) {
return previousValue + currentValue;
}, 0);
var donutWidth = Chartist.quantity(options.donutWidth);
if (donutWidth.unit === '%') {
donutWidth.value *= radius / 100;
}
// If this is a donut chart we need to adjust our radius to enable strokes to be drawn inside
// Unfortunately this is not possible with the current SVG Spec
// See this proposal for more details: http://lists.w3.org/Archives/Public/www-svg/2003Oct/0000.html
radius -= options.donut ? donutWidth.value / 2 : 0;
// If labelPosition is set to `outside` or a donut chart is drawn then the label position is at the radius,
// if regular pie chart it's half of the radius
if(options.labelPosition === 'outside' || options.donut) {
labelRadius = radius;
} else if(options.labelPosition === 'center') {
// If labelPosition is center we start with 0 and will later wait for the labelOffset
labelRadius = 0;
} else {
// Default option is 'inside' where we use half the radius so the label will be placed in the center of the pie
// slice
labelRadius = radius / 2;
}
// Add the offset to the labelRadius where a negative offset means closed to the center of the chart
labelRadius += options.labelOffset;
// Calculate end angle based on total sum and current data value and offset with padding
var center = {
x: chartRect.x1 + chartRect.width() / 2,
y: chartRect.y2 + chartRect.height() / 2
};
// Check if there is only one non-zero value in the series array.
var hasSingleValInSeries = this.data.series.filter(function(val) {
return val.hasOwnProperty('value') ? val.value !== 0 : val !== 0;
}).length === 1;
//if we need to show labels we create the label group now
if(options.showLabel) {
labelsGroup = this.svg.elem('g', null, null, true);
}
// Draw the series
// initialize series groups
for (var i = 0; i < this.data.series.length; i++) {
// If current value is zero and we are ignoring empty values then skip to next value
if (dataArray[i] === 0 && options.ignoreEmptyValues) continue;
var series = this.data.series[i];
seriesGroups[i] = this.svg.elem('g', null, null, true);
// If the series is an object and contains a name or meta data we add a custom attribute
seriesGroups[i].attr({
'ct:series-name': series.name
});
// Use series class from series data or if not set generate one
seriesGroups[i].addClass([
options.classNames.series,
(series.className || options.classNames.series + '-' + Chartist.alphaNumerate(i))
].join(' '));
var endAngle = startAngle + dataArray[i] / totalDataSum * 360;
// Use slight offset so there are no transparent hairline issues
var overlappigStartAngle = Math.max(0, startAngle - (i === 0 || hasSingleValInSeries ? 0 : 0.2));
// If we need to draw the arc for all 360 degrees we need to add a hack where we close the circle
// with Z and use 359.99 degrees
if(endAngle - overlappigStartAngle >= 359.99) {
endAngle = overlappigStartAngle + 359.99;
}
var start = Chartist.polarToCartesian(center.x, center.y, radius, overlappigStartAngle),
end = Chartist.polarToCartesian(center.x, center.y, radius, endAngle);
// Create a new path element for the pie chart. If this isn't a donut chart we should close the path for a correct stroke
var path = new Chartist.Svg.Path(!options.donut)
.move(end.x, end.y)
.arc(radius, radius, 0, endAngle - startAngle > 180, 0, start.x, start.y);
// If regular pie chart (no donut) we add a line to the center of the circle for completing the pie
if(!options.donut) {
path.line(center.x, center.y);
}
// Create the SVG path
// If this is a donut chart we add the donut class, otherwise just a regular slice
var pathElement = seriesGroups[i].elem('path', {
d: path.stringify()
}, options.donut ? options.classNames.sliceDonut : options.classNames.slicePie);
// Adding the pie series value to the path
pathElement.attr({
'ct:value': dataArray[i],
'ct:meta': Chartist.serialize(series.meta)
});
// If this is a donut, we add the stroke-width as style attribute
if(options.donut) {
pathElement.attr({
'style': 'stroke-width: ' + donutWidth.value + 'px'
});
}
// Fire off draw event
this.eventEmitter.emit('draw', {
type: 'slice',
value: dataArray[i],
totalDataSum: totalDataSum,
index: i,
meta: series.meta,
series: series,
group: seriesGroups[i],
element: pathElement,
path: path.clone(),
center: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle
});
// If we need to show labels we need to add the label for this slice now
if(options.showLabel) {
// Position at the labelRadius distance from center and between start and end angle
var labelPosition = Chartist.polarToCartesian(center.x, center.y, labelRadius, startAngle + (endAngle - startAngle) / 2),
interpolatedValue = options.labelInterpolationFnc(this.data.labels && !Chartist.isFalseyButZero(this.data.labels[i]) ? this.data.labels[i] : dataArray[i], i);
if(interpolatedValue || interpolatedValue === 0) {
var labelElement = labelsGroup.elem('text', {
dx: labelPosition.x,
dy: labelPosition.y,
'text-anchor': determineAnchorPosition(center, labelPosition, options.labelDirection)
}, options.classNames.label).text('' + interpolatedValue);
// Fire off draw event
this.eventEmitter.emit('draw', {
type: 'label',
index: i,
group: labelsGroup,
element: labelElement,
text: '' + interpolatedValue,
x: labelPosition.x,
y: labelPosition.y
});
}
}
// Set next startAngle to current endAngle.
// (except for last slice)
startAngle = endAngle;
}
this.eventEmitter.emit('created', {
chartRect: chartRect,
svg: this.svg,
options: options
});
}
/**
* This method creates a new pie chart and returns an object that can be used to redraw the chart.
*
* @memberof Chartist.Pie
* @param {String|Node} query A selector query string or directly a DOM element
* @param {Object} data The data object in the pie chart needs to have a series property with a one dimensional data array. The values will be normalized against each other and don't necessarily need to be in percentage. The series property can also be an array of value objects that contain a value property and a className property to override the CSS class name for the series group.
* @param {Object} [options] The options object with options that override the default options. Check the examples for a detailed list.
* @param {Array} [responsiveOptions] Specify an array of responsive option arrays which are a media query and options object pair => [[mediaQueryString, optionsObject],[more...]]
* @return {Object} An object with a version and an update method to manually redraw the chart
*
* @example
* // Simple pie chart example with four series
* new Chartist.Pie('.ct-chart', {
* series: [10, 2, 4, 3]
* });
*
* @example
* // Drawing a donut chart
* new Chartist.Pie('.ct-chart', {
* series: [10, 2, 4, 3]
* }, {
* donut: true
* });
*
* @example
* // Using donut, startAngle and total to draw a gauge chart
* new Chartist.Pie('.ct-chart', {
* series: [20, 10, 30, 40]
* }, {
* donut: true,
* donutWidth: 20,
* startAngle: 270,
* total: 200
* });
*
* @example
* // Drawing a pie chart with padding and labels that are outside the pie
* new Chartist.Pie('.ct-chart', {
* series: [20, 10, 30, 40]
* }, {
* chartPadding: 30,
* labelOffset: 50,
* labelDirection: 'explode'
* });
*
* @example
* // Overriding the class names for individual series as well as a name and meta data.
* // The name will be written as ct:series-name attribute and the meta data will be serialized and written
* // to a ct:meta attribute.
* new Chartist.Pie('.ct-chart', {
* series: [{
* value: 20,
* name: 'Series 1',
* className: 'my-custom-class-one',
* meta: 'Meta One'
* }, {
* value: 10,
* name: 'Series 2',
* className: 'my-custom-class-two',
* meta: 'Meta Two'
* }, {
* value: 70,
* name: 'Series 3',
* className: 'my-custom-class-three',
* meta: 'Meta Three'
* }]
* });
*/
function Pie(query, data, options, responsiveOptions) {
Chartist.Pie.super.constructor.call(this,
query,
data,
defaultOptions,
Chartist.extend({}, defaultOptions, options),
responsiveOptions);
}
// Creating pie chart type in Chartist namespace
Chartist.Pie = Chartist.Base.extend({
constructor: Pie,
createChart: createChart,
determineAnchorPosition: determineAnchorPosition
});
}(window, document, Chartist));
return Chartist;
}));
================================================
FILE: app/assets/javascripts/application/vendor/jquery.multibox.js
================================================
(function ($) {
'use strict';
function Multibox($el, options) {
this.$el = $el;
this.options = options;
this.draw();
this.listen();
}
Multibox.prototype.destroy = function destroy() {
this.$inputs.off();
this.$el.detach();
this.$container.replaceWith(this.$el);
if (this.previousType) {
this.$el.attr('type', this.previousType);
}
};
Multibox.prototype.draw = function draw() {
var classNames = this.options.classNames;
var inputAutofocus = this.$el.attr('autofocus');
var inputType = this.$el.attr('type');
var inputValue = this.$el.val();
var focusIndex;
var inputIndex;
var text;
if (inputType !== 'hidden') {
this.previousType = inputType;
this.$el.attr('type', 'hidden');
}
this.$container = $('
', {
'class': classNames.container
});
var size = Array.apply(null, Array(this.options.inputCount));
this.$inputs = $();
$.each(size, function () {
this.$inputs = this.$inputs.add($(' ', {
'class': classNames.input,
maxlength: 1,
size: 1,
type: 'text'
}));
}.bind(this));
this.$container .append(this.$inputs);
this.$el.replaceWith(this.$container);
this.$container.append(this.$el);
text = this.filterString(inputValue);
if (text.length) {
inputIndex = this.setFromString(0, text);
}
if (inputAutofocus) {
if (inputIndex === undefined) {
focusIndex = 0;
} else {
focusIndex = (inputIndex == this.$inputs.length ? inputIndex - 1 : inputIndex);
}
this.$inputs.eq(focusIndex).focus();
}
};
Multibox.prototype.handleKeydown = function handleKeydown(event) {
var $input = $(event.target);
var $prev;
if (event.keyCode === 8) {
event.preventDefault();
$prev = $input.prev();
if ($prev.length) {
$prev.focus();
}
if (event.target.value) {
$input.val('');
} else {
$prev.val('');
}
}
this.update();
};
Multibox.prototype.handleInput = function handleInput(event) {
var $input = $(event.target);
var $next = $input.next();
var value = $input.val();
var filtered = this.filterString(value);
$input.val(filtered);
if (filtered && $next.length) {
$next.focus();
}
this.update();
};
Multibox.prototype.handlePaste = function handlePaste(event) {
event.preventDefault();
var $input = $(event.target);
var clipboardData = event.originalEvent.clipboardData;
var text = clipboardData.getData('text');
var filtered = this.filterString(text);
if (!filtered.length) return;
var inputIndex = this.setFromString(this.$inputs.index($input), filtered);
var focusIndex = (inputIndex == this.$inputs.length ? inputIndex - 1 : inputIndex);
this.$inputs.eq(focusIndex).focus();
this.update();
};
Multibox.prototype.listen = function listen() {
this.$inputs.on('input', this.handleInput.bind(this));
this.$inputs.on('keydown', this.handleKeydown.bind(this));
this.$inputs.on('paste', this.handlePaste.bind(this));
};
Multibox.prototype.filterString = function filterString(str) {
return str.replace(this.options.regex, '');
};
Multibox.prototype.setFromString = function setFromString(index, str) {
var inputIndex = index;
var strIndex = 0;
while (this.$inputs.eq(inputIndex).length && str[strIndex]) {
this.$inputs.eq(inputIndex).val(str[strIndex]);
inputIndex++;
strIndex++;
}
return inputIndex;
};
Multibox.prototype.update = function update() {
var values = [];
var value;
this.$inputs.each(function(i, input) {
values.push(input.value);
});
value = values.join('');
this.$el
.val(value)
.trigger('change');
};
$.fn.multibox = function multibox(options) {
var instance;
if (typeof options === 'object' || options == undefined) {
options = (options || {});
options = $.extend({}, {
classNames: {
container: 'multibox',
input: 'multibox-input'
},
inputCount: 4,
regex: /\D/g
}, options);
if (this.length) {
instance = new Multibox(this, options);
this.data('multibox', instance);
}
} else if (options === 'destroy') {
if (this.data('multibox')) {
instance = this.data('multibox');
instance.destroy();
this.data('multibox', null);
}
}
};
}(jQuery));
================================================
FILE: app/assets/stylesheets/application/application.scss
================================================
@import 'global/reset';
@import 'global/variables';
@import 'global/mixins';
@import 'global/fonts';
@import 'vendor/*';
@import 'elements/*';
@import 'components/*';
@import 'global/utility';
html.main {
font-family: 'Source Sans Pro', sans-serif;
font-size:14px;
height: 100%;
max-height: 100%;
background:$backgroundGrey;
body {
display:flex;
flex-direction: column;
height: 100%;
max-height: 100%;
overflow-x:hidden;
}
}
html.subPage {
font-family: 'Source Sans Pro', sans-serif;
font-size:14px;
background:$backgroundGrey;
body {
padding-top:100px;
padding-bottom:100px;
}
.subPage__logo {
margin-bottom:40px;
text-align:center;
}
}
.turbolinks-progress-bar {
background-color: $darkBlue;
}
================================================
FILE: app/assets/stylesheets/application/components/_admin_stats.scss
================================================
.adminStats {
display:flex;
}
.adminStats__stat {
flex:1 1 auto;
text-align: center;
dt {
font-weight:300;
color:#999;
}
dd {
font-size:26px;
font-weight:bold;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_button_set.scss
================================================
.buttonSet {
.button {
margin-right:7px;
}
}
.buttonSet--center {
.button {
margin:0 5px;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_checkbox_list.scss
================================================
.checkboxList {
background:#fff;
border:1px solid #e4e8ef;
font:inherit;
width:100%;
color:$darkBlue;
font-weight:600;
appearance:none;
border-radius:4px;
}
.checkboxList__item {
padding:8px 10px;
display:flex;
}
.checkboxList__item + .checkboxList__item{
border-top:1px solid #e4e8ef;
}
.checkboxList__checkbox {
margin-right:15px;
}
.checkboxList__actualLabel {
color:$darkBlue;
font-weight:600;
}
.checkBoxList__text {
font-size:12px;
line-height:1.5;
color:$subBlue;
margin-top:3px;
}
.checkboxList__devEvent {
font-family:'Droid Sans Mono', fixed;
font-size:13px;
font-weight:bold;
}
================================================
FILE: app/assets/stylesheets/application/components/_credential_list.scss
================================================
.credentialList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.credentialList__item {
background:#fff;
}
.credentialList__item:nth-child(even) {
background:none;
}
.credentialList__item + .credentialList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.credentialList__link {
padding:15px;
display:flex;
&:hover {
background:#f2f5f8 !important;
}
}
.credentialList__properties {
flex: 1 1 auto;
min-width:1px;
}
.credentialList__name {
font-size:16px;
font-weight:600;
margin-bottom:10px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
line-height:1.2;
.label {
vertical-align:2px;
margin-left:4px;
}
}
.credentialList__key {
font-size:12px;
font-family:'Droid Sans Mono', fixed;
color:#999;
}
.credentialList__type {
margin-right:10px;
width:40px;
}
.credentialList__usedAt {
flex: 0 1 auto;
max-width:150px;
text-align:right;
margin-left:25px;
font-size:12px;
line-height:1.4;
color:#999;
}
.credentialList__usedAt--active {
color:$green;
.credentialList__usedAtTitle {
background-color:$green;
}
}
.credentialList__usedAt--quiet {
color:#bac647;
.credentialList__usedAtTitle {
background-color:#bac647;
}
}
.credentialList__usedAt--dormant {
color:#c7ad46;
.credentialList__usedAtTitle {
background-color:#c7ad46;
}
}
.credentialList__usedAt--inactive {
color:#d05026;
.credentialList__usedAtTitle {
background-color:#d05026;
}
}
.credentialList__usedAtTitle {
margin-bottom:3px;
background-color:#999;
color:#fff;
display:inline-block;
padding:1px 4px;
font-size:10px;
border-radius:3px;
}
================================================
FILE: app/assets/stylesheets/application/components/_danger_zone.scss
================================================
.dangerZone {
border:3px dashed $red;
border-radius:4px;
padding:25px;
color:$red;
background:lighten($red, 42%);
}
================================================
FILE: app/assets/stylesheets/application/components/_data_table.scss
================================================
.dataTable {
width:100%;
border:1px solid #ddd;
font-size:14px;
box-shadow:0 0 5px rgba(0,0,0,0.3);
}
.dataTable tr td {
border-left:1px solid #ddd;
padding:8px;
background:#fff;
}
.dataTable tr th {
text-align:left;
padding:8px;
background-color:#fffdf4;
font-weight:600;
vertical-align:top;
}
.dataTable thead td {
font-weight:600;
border-left:0 !important;
background:#fffdf4;
padding:8px 9px;
border-bottom:2px solid #222;
}
.dataTable tbody tr:nth-child(even) td {
background:#f5f5f5;
}
.dataTable tbody tr:hover td {
background:#ededed;
}
.dataTable__centerCell {
text-align:center;
}
.dataTable__rightCell {
text-align:right;
}
.dataTable__empty {
padding:40px 0 !important;
text-align:center;
color:#999;
font-style:italic;
font-size:12px;
&:hover {
background:#fff !important;
}
}
.dataTable__inputCell {
padding:0 !important;
input {
width:100%;
padding:8px;
border:0;
font:inherit;
background:transparent;
font-weight:bold;
color:#fb8424;
}
}
.dataTable__redRow {
td {
background-color:#fff0f1 !important;
color:#cd2f3b !important;
.u-link { color:#cd2f3b;}
}
}
================================================
FILE: app/assets/stylesheets/application/components/_delivery_list.scss
================================================
.deliveryList {
color:$darkBlue;
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.deliveryList__item {
background:#fff;
padding:15px;
}
.deliveryList__item:nth-child(even) {
background:none;
}
.deliveryList__item + .deliveryList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.deliveryList__top {
display:flex;
justify-content:space-between;
align-items:flex-start;
}
.deliveryList__time {
}
.deliveryList__status {
display:flex;
}
.deliveryList__secure {
height:12px;
margin-right:7px;
margin-top:2px;
}
.deliveryList__errorCode {
font-size:12px;
color:$subBlue;
margin-top:5px;
}
.deliveryList__error {
margin-top:5px;
font-size:12px;
color:$subBlue;
}
.deliveryList__error--output {
background:$subBlue;
color:#fff;
font-size:10px;
font-family:'Droid Sans Mono', fixed;
padding:10px;
border-radius:4px;
margin-top:8px;
word-wrap:break-word;
}
.deliveryList__error--output-ref {
opacity:0.5;
}
.deliveryList__item--header {
p + p {
margin-top:8px;
}
}
.deliveryList__techLink {
display:inline-block;
font-size:10px;
color:$subBlue;
margin-top:8px;
text-decoration: underline;
}
.deliveryList-removeLink {
text-align:right;
font-size:12px;
color:#999;
margin-top:15px;
}
================================================
FILE: app/assets/stylesheets/application/components/_domain_list.scss
================================================
.domainList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.domainList__item {
display:block;
background:#fff;
padding:15px;
display:flex;
justify-content:space-between;
}
.domainList__item:nth-child(even) {
background:none;
}
.domainList__item + .domainList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.domainList__details {
flex: 1 1 auto;
min-width: 1px;
}
.domainList__properties {
text-align:right;
flex:0 0 auto;
margin-left:25px;
}
.domainList__name {
font-size:16px;
font-weight:600;
margin-bottom:6px;
overflow:hidden;
text-overflow:ellipsis;
span.label {
vertical-align:2px;
}
}
.domainList__verificationTime {
color:#999;
}
.domainList__links {
margin-top:12px;
display:flex;
justify-content:flex-end;
font-size:12px;
text-decoration: underline;
a {
margin-left:10px;
}
}
.domainList__delete {
color:$red;
margin-left:10px;
}
.domainList__verificationLink {
background:$blue;
color:#fff;
padding:1px 7px;
border-radius:4px;
font-size:12px;
}
.domainList__checks {
display:flex;
}
.domainList__check {
margin-right:15px;
font-size:12px;
}
.domainList__check--ok {
background:image-url('icons/tick-green.svg') no-repeat 0 3px / 12px;
padding-left:15px;
color:$green;
}
.domainList__check--neutral {
background:image-url('icons/tick-grey.svg') no-repeat 0 3px / 12px;
padding-left:15px;
color:#aaa;
}
.domainList__check--neutral-cross {
background:image-url('icons/cross-grey.svg') no-repeat 0 3px / 9px;
padding-left:12px;
color:#aaa;
}
.domainList__check--warning {
background:image-url('icons/cross-orange.svg') no-repeat 0 3px / 9px;
padding-left:12px;
color:$orange;
}
.domainList__check a:hover {
text-decoration:underline;
}
================================================
FILE: app/assets/stylesheets/application/components/_endpoint_list.scss
================================================
.endpointList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.endpointList__item {
background:#fff;
}
.endpointList__item:nth-child(even) {
background:none;
}
.endpointList__item + .endpointList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.endpointList__link {
padding:15px;
display:block;
display:flex;
&:hover {
background:#f2f5f8 !important;
}
}
.endpointList__main {
width:60%;
flex: 1 1 auto;
}
.endpointList__details {
flex: 1 1 auto;
width:40%;
}
.endpointList__name {
font-size:16px;
font-weight:600;
margin-bottom:8px;
}
.endpointList__url {
font-size:12px;
color:#999;
}
.endpointList__details {
line-height:1.5;
}
================================================
FILE: app/assets/stylesheets/application/components/_error_explanation.scss
================================================
.errorExplanation {
border:1px solid $orange;
margin-bottom:25px;
color:$orange;
padding:15px;
box-shadow:0 0 10px lighten($red, 30%);
background:#fff;
border-radius:4px;
line-height:1.5;
}
.errorExplanation h2 {
display:none;
}
.errorExplanation p {
display:none;
}
.errorExplanation ul li {
list-style:disc;
margin-left:20px;
}
html.subPage {
.errorExplanation {
background:none;
padding-left:15px;
line-height:1.3;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_field_set.scss
================================================
.fieldSet {
}
.fieldSet__field {
display:flex;
}
.fieldSet__field + .fieldSet__field {
margin-top:20px;
}
.fieldSet__label {
display:block;
font-weight:600;
text-transform: uppercase;
font-size:12px;
color:$darkBlue;
margin-top:11px;
width:20%;
}
.fieldSet--wide .fieldSet__label {
width:40%;
}
.fieldSet__input {
flex: 0 0 auto;
width:80%;
}
.fieldSet--wide .fieldSet__input {
width:60%;
}
.fieldSet__text {
font-size:12px;
line-height:1.5;
color:$subBlue;
margin-top:5px;
}
.fieldSetSubmit {
margin-left:20%;
margin-top:40px;
display:flex;
}
.fieldSetSubmit--wide {
margin-left:40%;
}
.fieldSetSubmit__delete {
flex: 1 0 auto;
text-align:right;
.button {
margin-right:0;
}
}
.fieldSet__title {
margin-top:40px;
font-weight:600;
font-size:16px;
margin-left:20%;
color:$blue;
border-bottom:2px solid #e4e8ef;
padding-bottom:5px;
margin-bottom:20px;
}
.fieldSet__title--noMargin {
margin-top:0;
}
.fieldSet__title--withSubText {
margin-bottom:5px;
}
.fieldSet__titleSubText {
margin-left:20%;
font-size:12px;
color:$subBlue;
line-height:1.5;
margin-bottom:20px;
}
.fieldSet--compact {
.fieldSet__field {
display:block;
}
.fieldSet__field + .fieldSet__field {
margin-top:0;
}
.fieldSet__label {
width:100%;
margin-bottom:5px;
}
.fieldSet__input {
width:100%;
}
.fieldSet__fieldPair {
display:flex;
justify-content: space-between;
.fieldSet__field {
width:48%;
}
}
}
.fieldSet__inputPair {
display:flex;
justify-content: space-between;
.input + .input {
margin-left:10px;
}
}
.fieldSet__checkboxListAfter {
margin-bottom:6px;
}
.fieldSet__selectList {
select + select {
margin-top:6px;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_flash_display.scss
================================================
================================================
FILE: app/assets/stylesheets/application/components/_flash_message.scss
================================================
html.main .flashMessage {
position:fixed;
background:$red;
z-index:5000;
left:25px;
top:25px;
width:300px;
color:#fff;
padding:15px;
border-radius:4px;
font-size:16px;
box-shadow:0 0 15px rgba(0,0,0,0.8);
cursor:pointer;
}
html.main .flashMessage--notice {
background:$green;
}
html.subPage .flashMessage {
background:$red;
color:#fff;
font-size:14px;
padding:15px;
line-height:1.4;
}
html.subPage .flashMessage--notice {
background:$green;
}
================================================
FILE: app/assets/stylesheets/application/components/_footer.scss
================================================
.footer__links {
display:flex;
margin-left:auto;
align-items: center;
font-size:13px;
color:#999;
li {
height:24px;
}
li + li {
margin-left:18px;
}
a {
text-decoration: underline;
}
}
.footer__name {
height:16px;
background:image-url('icon.svg') no-repeat 0 0;
background-size:16px;
padding-left:22px;
font-weight:bold;
}
================================================
FILE: app/assets/stylesheets/application/components/_grid.scss
================================================
.row {
clear:both;
margin-left:-20px;
margin-right:-20px;
@include clearfix;
}
.row--noPadding {
margin-left:0;
margin-right:0;
.col {
padding-left:0;
padding-right:0;
}
}
.col {
float:left;
padding-left:20px;
padding-right:20px;
}
.col--1 { width:5%; }
.col--2 { width:10%; }
.col--3 { width:15%; }
.col--4 { width:20%; }
.col--5 { width:25%; }
.col--6 { width:30%; }
.col--7 { width:35%; }
.col--8 { width:40%; }
.col--9 { width:45%; }
.col--10 { width:50%; }
.col--11 { width:55%; }
.col--12 { width:60%; }
.col--13 { width:65%; }
.col--14 { width:70%; }
.col--15 { width:75%; }
.col--16 { width:80%; }
.col--17 { width:85%; }
.col--18 { width:90%; }
.col--19 { width:95%; }
.row--2col {
margin-left:0;
margin-right:0;
.col:first-child {
padding-left:0;
padding-right:10px;
}
.col:last-child {
padding-left:10px;
padding-right:0;
}
}
@media(max-width: 1000px) {
.col--collapse {
width: 100%;
margin-bottom: 50px;
&:last-child {
margin-bottom: 0;
}
}
}
================================================
FILE: app/assets/stylesheets/application/components/_headers_list.scss
================================================
.headersList {
}
.headersList__item {
display:flex;
font-family:'Droid Sans Mono', fixed;
font-size:12px;
justify-content:space-between;
dt {
color:$blue;
width:30%;
text-align:right;
font-weight:bold;
}
dd {
width:68%;
word-wrap:break-word;
}
}
.headersList__item + .headersList__item {
margin-top:15px;
}
================================================
FILE: app/assets/stylesheets/application/components/_inlineError.scss
================================================
.inlineError {
background:$red;
color:#fff;
padding:15px;
border-radius:4px;
}
================================================
FILE: app/assets/stylesheets/application/components/_invoice_list.scss
================================================
.invoiceList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.invoiceList__item {
background:#fff;
}
.invoiceList__item:nth-child(even) {
background:none;
}
.invoiceList__item + .invoiceList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.invoiceList__link {
padding:15px;
display:flex;
&:hover {
background:#f2f5f8 !important;
}
}
.invoiceList__number {
width:70px;
flex: 0 0 auto;
font-weight:bold;
}
.invoiceList__date {
flex: 1 1 auto;
}
.invoiceList__total {
width:100px;
flex: 0 0 auto;
}
.invoiceList__status {
width:50px;
text-align: right;
flex:0 0 auto;
}
================================================
FILE: app/assets/stylesheets/application/components/_ip_list.scss
================================================
.ipList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.ipList__item {
display:block;
background:#fff;
padding:15px;
justify-content:space-between;
}
.ipList__item:nth-child(even) {
background:none;
}
.ipList__item + .ipList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.ipList__name {
font-size:16px;
font-weight:600;
margin-bottom:10px;
}
.ipList__address {
display:flex;
}
.ipList__address + .ipList__address {
margin-top:5px;
}
.ipList__ipv4 {
width:120px;
}
.ipList__ipv6 {
width:200px;
}
.ipList__address--header {
font-size:12px;
color:#999;
border-bottom:1px solid #ccc;
padding-bottom:4px;
}
================================================
FILE: app/assets/stylesheets/application/components/_ip_pool_rule_list.scss
================================================
.ipPoolRuleList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.ipPoolRuleList__item {
background:#fff;
}
.ipPoolRuleList__item:nth-child(even) {
background:none;
}
.ipPoolRuleList__item + .ipPoolRuleList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.ipPoolRuleList__link {
padding:15px;
display:block;
&:hover {
background:#f2f5f8 !important;
}
}
.ipPoolRuleList__condition {
display:flex;
dt {
width:180px;
color:#999;
padding-top:1px;
}
dd {
ul li {
line-height:1.4;
}
}
}
.ipPoolRuleList__condition + .ipPoolRuleList__condition {
margin-top:15px;
}
.ipPoolRuleListDefault {
text-align:center;
margin-top:25px;
color:#999;
}
================================================
FILE: app/assets/stylesheets/application/components/_large_list.scss
================================================
.largeList {
font-size:16px;
color:$darkBlue;
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.largeList__item {
display:block;
background:#fff;
}
.largeList__item:nth-child(even) {
background:none;
}
.largeList__item + .largeList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.largeList__item.is-highlighted {
background:$blue;
color:#fff;
}
.largeList__link {
display:block;
padding:15px;
}
.largeList__link:hover {
background:$blue;
color:#fff;
}
.largeList__link:active {
background:darken($blue, 5%);
}
.largeList__subText {
color:$subBlue;
font-size:13px;
margin-top:5px;
}
.largeList__rightLabel {
float:right;
line-height:0.8;
}
================================================
FILE: app/assets/stylesheets/application/components/_limit.scss
================================================
.limits {
font-size:16px;
color:$darkBlue;
border-radius:4px;
background:#fff;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
display:flex;
}
.limits__limit {
flex: 1 1 auto;
width:50%;
padding:15px;
text-align:center;
}
.limits__limit + .limits__limit {
border-left:1px solid #efefef;
}
.limits__title {
font-size:14px;
margin-bottom:5px;
font-weight:600;
}
.limits__value {
font-size:32px;
font-weight:900;
color:$blue;
}
.limits__frequency {
font-size:14px;
color:#999
}
================================================
FILE: app/assets/stylesheets/application/components/_login_form.scss
================================================
.loginForm {}
.loginForm__input {
margin-bottom: 15px;
}
.loginForm__submit {
display: flex;
justify-content: space-between;
align-items: center;
}
.loginForm__links {
font-size: 12px;
color: #999;
text-decoration: underline;
line-height: 1.7;
}
.loginForm__divider {
margin-top: 25px;
margin-bottom: 25px;
border-top: 1px solid #e4e8ef;
}
.loginForm__localTitle {
text-align: center;
margin-bottom: 15px;
color: #999;
}
================================================
FILE: app/assets/stylesheets/application/components/_mail_graph.scss
================================================
.mailGraph {
}
.mailGraph__startTime {
}
.mailGraph__graph {
min-height:230px;
margin-bottom:4px;
.ct-series-a .ct-line { stroke:$blue;}
.ct-series-a .ct-area { fill:$blue; fill-opacity:0.2;}
.ct-series-b .ct-line { stroke:$turquoise;}
.ct-series-b .ct-area { fill:$turquoise;fill-opacity:0.2;}
.ct-point { stroke-width: 0; }
.ct-line { stroke-width:1px; }
.ct-area { fill-opacity: 0.4; }
}
.mailGraph__empty {
margin:100px 0;
text-align:center;
color:#aaa;
}
.mailGraph__key {
font-size:12px;
margin-bottom:15px;
float:right;
li {
float:left;
margin-left:10px;
color:$turquoise;
}
li:before {
display:block;
float:left;
width:10px;
content: " ";
margin-top:3px;
height:10px;
border:1px solid $turquoise;
background:lighten($turquoise, 20%);
margin-right:6px;
}
li.mailGraph__key--out {
color:$blue;
&:before {
border-color:$blue ;
background:lighten($blue, 30%);
}
}
}
.mailGraph__labels {
display:flex;
margin-left:40px;
justify-content:space-between;
font-size:12px;
color:#999;
}
================================================
FILE: app/assets/stylesheets/application/components/_message_activity.scss
================================================
.messageActivity {
}
.messageActivity__event {
display:flex;
}
.messageActivity__event + .messageActivity__event {
border-top:1px solid #ddd;
padding-top:15px;
margin-top:15px;
}
.messageActivity__timestamp {
width:170px;
font-size:12px;
color:#999;
flex: 0 0 auto;
}
.messageActivity__details {
background:image-url('icons/conveyor.svg') no-repeat 0 2px / 24px;
padding-left:35px;
}
.messageActivity--detailsDelivery {
background-image:image-url('icons/truck.svg');
}
.messageActivity--detailsClick {
background:image-url('icons/mouse.svg') no-repeat 5px 2px / 12px;
}
.messageActivity--detailsLoad {
background-image:image-url('icons/eye.svg');
}
.messageActivity__subject {
font-weight:600;
font-size:14px;
word-break:break-all;
}
.messageActivity__extra {
margin-top:4px;
color:#999;
font-size:12px;
line-height:1.4;
}
================================================
FILE: app/assets/stylesheets/application/components/_message_header.scss
================================================
.messageHeader {
margin:20px 35px;
}
.messageHeader__subject {
font-size:18px;
font-weight:700;
margin-bottom:6px;
}
.messageHeader__status {
margin-bottom:4px;
}
.messageHeader__timestamp {
color:$subBlue;
}
.messageHeader__basicProperties {
display:flex;
dl {
margin-right:25px;
display:flex;
dt {
color:$subBlue;
margin-bottom:5px;
margin-right:15px;
}
dd {
font-weight:600;
}
}
}
.messageHeader__header {
background-size:35px;
background-repeat:no-repeat;
background-position:right 0;
}
.messageHeader__header--incoming {
background-image:image-url('icons/incoming-mail.svg');
}
.messageHeader__header--outgoing {
background-image:image-url('icons/outgoing-mail.svg');
}
================================================
FILE: app/assets/stylesheets/application/components/_message_list.scss
================================================
.messageList {
box-shadow:0 0 10px rgba(0,0,0,0.15);
border-radius:4px;
overflow:hidden;
}
.messageList__message + .messageList__message {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.messageList__link {
display:flex;
padding:15px;
background:#fff;
&:hover {
background:#f2f5f8 !important;
}
}
.messageList__message:nth-child(even) {
.messageList__link {
background:transparent;
}
}
.messageList__details {
flex: 1 1 auto;
overflow: hidden;
min-width: 1px;
background-repeat:no-repeat;
background-size:16px;
background-position:0 2px;
padding-left:25px;
}
.messageList__details--incoming {
background-image:image-url('icons/incoming-mail.svg');
}
.messageList__details--outgoing {
background-image:image-url('icons/outgoing-mail.svg');
}
.messageList__subject {
font-weight:600;
margin-bottom:7px;
line-height:1.4;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.messageList__addresses {
display:flex;
line-height:1.4;
font-size:12px;
dt {
font-weight:600;
}
dd {
margin-left:15px;
margin-right:25px;
}
}
.messageList__meta {
flex: 0 0 auto;
margin-left:15px;
justify-self: flex-end;
text-align:right;
}
.messageList__timestamp {
color:#999;
font-size:12px;
margin-bottom:5px;
}
================================================
FILE: app/assets/stylesheets/application/components/_message_properties_page.scss
================================================
.messagePropertiesPage {
display:flex;
justify-content:space-between;
}
.messagePropertiesPage__left {
width:45%;
}
.messagePropertiesPage__right {
border-left:3px solid #eee;
padding-left:35px;
width:52%;
}
.messagePropertiesPage__property {
margin-bottom:25px;
min-width:1px;
dt {
color:$subBlue;
margin-bottom:3px;
}
dd {
font-size:16px;
font-weight:600;
text-overflow:ellipsis;
overflow:hidden;
white-space:nowrap;
}
}
.messagePropertiesPage__property--locked {
background:image-url('icons/lock.svg') no-repeat 0 1px / 14px;
padding-left:20px;
}
.messagePropertiesPage__propertyPair {
display:flex;
justify-content:space-between;
dl {
width:47%;
}
}
.messagePropertiesPage__title {
font-size:20px;
font-weight:700;
margin-bottom:25px;
}
================================================
FILE: app/assets/stylesheets/application/components/_message_search.scss
================================================
.messageSearch {
margin-bottom:25px;
position:relative;
}
.messageSearch__help {
position:absolute;
z-index:100;
right:20px;
top:11px;
font-size:12px;
color:$subBlue;
text-decoration:underline;
}
.messageSearch__input {
width:100%;
margin:0;
border:2px solid #e0e7f3;
border-radius:25px;
padding:6px 13px;
font:inherit;
font-size:14px;
font-weight:600;
position:relative;
color:$darkBlue;
background:image-url('icons/search.svg') #fff no-repeat 12px 7px / 19px;
padding-left:38px;
padding-right:150px;
&::placeholder {
color:#98a5c0;
font-weight:300;
}
&:focus {
border-color:$blue;
}
&.is-spinning {
background-image:image-url('spinner-sub.gif');
background-position: 12px 5px;
}
}
.messageSearch__helpBox {
color:$darkBlue;
margin-top:25px;
border-radius:4px;
background:#fffdf1;
padding:25px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
display:flex;
justify-content:space-between;
}
.messageSearch__left {
width:40%;
}
.messageSearch__helpBoxTitle {
font-size:18px;
font-weight:600;
margin-bottom:10px;
}
.messageSearch__helpBoxText {
line-height:1.5;
font-size:14px;
color:$subBlue;
}
.messageSearch__right {
width:55%;
}
.messageSearch__definition {
dt {
font-family:'Droid Sans Mono', fixed;
font-weight:bold;
font-size:15px;
color:$blue;
}
dd {
font-size:13px;
margin-top:4px;
code {
font-family:'Droid Sans Mono', fixed;
font-size:12px;
color:darken($blue, 15%);
}
}
}
.messageSearch__definition + .messageSearch__definition {
margin-top:18px;
}
================================================
FILE: app/assets/stylesheets/application/components/_multibox.scss
================================================
.multibox {
display: flex;
justify-content: space-between;
margin: 40px 0;
}
.multibox__input {
width:80px;
height:70px;
font: inherit;
font-weight: 700;
font-size:38px;
text-align:center;
}
================================================
FILE: app/assets/stylesheets/application/components/_nav_bar.scss
================================================
.navBar {
background:$veryDarkBlue;
padding:10px 35px;
color:#fff;
ul {
display:flex;
}
}
.navBar--secondary {
background:lighten($veryDarkBlue, 44%);
.navBar__link.is-active {
color:$veryDarkBlue;
}
}
.navBar--tertiary {
background:#fff;
border:1px solid lighten($subBlue, 30%);
border-left:0;
border-right:0;
.navBar__link {
color:$subBlue;
}
.navBar__link.is-active {
color:$veryDarkBlue;
}
}
.navBar__item:not(:last-child) {
margin-right:35px;
}
.navBar__link.is-active {
color:#8abdff;
font-weight:600;
}
.navBar__item--end {
margin-left:auto;
}
.navBar__link:hover {
text-decoration:underline;
}
.navBar__itemCounter {
background:$red;
border-radius:4px;
padding:2px 2px 1px 2px;
line-height:1;
font-size:10px;
vertical-align:1px;
font-weight:300;
min-width:20px;
display:inline-block;
text-align:center;
margin-left:5px;
&.is-empty {
background-color:$darkBlue;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_new_message_type.scss
================================================
.newMessageType {
background-repeat:no-repeat;
background-size:20px;
background-position:15px 15px;
background-color:#4bc9c5;
color:#fff;
padding:15px;
padding-left:46px;
border-radius:4px;
border:1px solid darken(#4bc9c5, 10%);
}
.newMessageType--outgoing {
background-color:#0e69d5;
border-color:darken(#0e69d5, 10%);
background-image:image-url('icons/outgoing-mail-white.svg');
}
.newMessageType--incoming {
background-image:image-url('icons/incoming-mail-white.svg');
}
================================================
FILE: app/assets/stylesheets/application/components/_no_data.scss
================================================
.noData {
text-align:center;
border-radius:4px;
padding:30px;
padding-top:50px;
box-shadow:0 0 10px rgba(0,0,0,0.15);
}
.noData--clean {
box-shadow:none;
background-color:transparent;
}
.noData__title {
font-size:22px;
margin-bottom:10px;
font-weight:700;
}
.noData__text {
color:#888;
font-size:16px;
line-height:1.5;
}
.noData__button {
margin-top:20px;
}
.noData__postButtonText {
margin:auto;
margin-top:15px;
line-height:1.5;
width:70%;
color:$subBlue;
}
================================================
FILE: app/assets/stylesheets/application/components/_page_content.scss
================================================
.pageContent {
margin:35px;
}
.pageContent__intro {
font-size:20px;
line-height:30px;
font-weight:300;
color:$subBlue;
}
.pageContent__title {
font-size:20px;
margin-bottom:10px;
font-weight:700;
color:$darkBlue;
}
.pageContent--compact {
max-width:600px;
margin:60px auto;
}
.pageContent__subTitle {
font-size:18px;
font-weight:600;
border-bottom:1px solid #ddd;
padding-bottom:3px;
margin-bottom:10px;
}
.pageContent__text {
line-height:1.5;
margin-bottom:15px;
.label {
vertical-align:1px;
margin-right:2px;
}
}
.pageContent__pageEntriesInfo {
font-size:12px;
color:$subBlue;
margin-bottom:10px;
}
.pageContent__definitions {
overflow:hidden;
dt {
width:30%;
float:left;
color:$subBlue;
}
dd {
margin-left:35%;
margin-bottom:15px;
word-wrap:break-word;
}
}
.pageContent__definitionCode {
font-size:16px;
font-weight:bold;
font-family:'Droid Sans Mono', fixed;
}
.pageContent__definitionCode + .pageContent__definitionText {
margin-top:6px;
}
.pageContent__definitionText {
color:$subBlue;
}
.pageContent__list {
line-height:1.5;
li {
list-style:square;
margin-left:25px;
}
li + li {
margin-top:15px;
}
}
.pageContent__helpLink {
a {
background:image-url('icons/help.svg') no-repeat 0 2px / 15px;
padding-left:20px;
text-decoration:underline;
color:$blue;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_page_header.scss
================================================
.pageHeader {
background:$darkBlue;
padding:22px 25px;
}
.pageHeader__title {
font-size:26px;
font-weight:300;
color:#fff;
}
.pageHeader__titlePrevious {
opacity:0.2;
}
================================================
FILE: app/assets/stylesheets/application/components/_pagination.scss
================================================
nav.pagination {
font-size:12px;
text-align:center;
margin:25px 0;
span.page.current, a {
color:$blue;
display:inline-block;
line-height:1.3;
border:1px solid lighten($subBlue, 25%);
padding:3px 10px;
text-decoration:none;
border-radius:4px;
background:#fff;
margin:0 2px;
}
a:hover {
background-color:lighten(#ccc, 20%);
}
span.page.current {
background:$blue;
color:#fff;
border-color:darken($blue, 15%);
}
}
================================================
FILE: app/assets/stylesheets/application/components/_rentention_limits.scss
================================================
.retentionLimits {
}
.retentionLimits__limit {
display:flex;
align-items: center;
}
.retentionLimits__limit + .retentionLimits__limit {
margin-top:25px;
}
.retentionLimits__label {
width:200px;
flex: 0 0 auto;
font-weight:bold;
margin-right:25px;
}
.retentionLimits__info {
border-left:4px solid $blue;
padding-left:25px;
}
.retentionLimits__value {
color:$blue;
font-size:22px;
font-weight:700;
margin-bottom:6px;
}
.retentionLimits__text {
color:$subBlue;
font-size:12px;
line-height:1.5;
}
================================================
FILE: app/assets/stylesheets/application/components/_route_list.scss
================================================
.routeList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.routeList__item {
background:#fff;
}
.routeList__item:nth-child(even) {
background:none;
}
.routeList__item + .routeList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.routeList__link {
padding:15px;
display:block;
&:hover {
background:#f2f5f8 !important;
}
}
.routeList__name {
font-size:16px;
font-weight:600;
margin-bottom:13px;
}
.routeList__details {
display:flex;
justify-content:space-between;
min-width:1px;
}
.routeList__endpoint {
flex: 1 1 auto;
overflow:hidden;
color:#999;
text-overflow:ellipsis;
white-space:nowrap;
line-height:1.1;
background:image-url('icons/web.svg') no-repeat 0 0 / 12px;
padding-left:18px;
font-size:13px;
}
.routeList__endpoint--smtp_endpoint {
background-image:image-url('icons/email.svg');
background-size:12px;
background-position:0 1.5px;
}
.routeList__endpoint--address_endpoint {
background-image:image-url('icons/email.svg');
background-size:12px;
background-position:0 1.5px;
}
.routeList__spamMode {
font-size:12px;
color:#999;
margin-left:15px;
flex: 0 0 auto;
}
================================================
FILE: app/assets/stylesheets/application/components/_route_name_input.scss
================================================
.routeNameInput {
display:flex;
align-items:center;
}
.routeNameInput__at {
margin:0 7px;
font-size:18px;
color:$subBlue;
}
.routeNameInput__name {
width:40%;
}
.routeNameInput__domain {
}
================================================
FILE: app/assets/stylesheets/application/components/_server_header.scss
================================================
.serverHeader {
background:$darkBlue;
padding:25px;
display:flex;
color:#fff;
position:relative;
}
.serverHeader__stripe {
right:-35px;
margin-top:-5px;
background:#636363;
font-size:10px;
font-weight:600;
text-transform: uppercase;
text-align:center;
width:130px;
padding:4px 0;
transform:rotate(45deg);
position:absolute;
}
.serverHeader__stripe--live {
background-color:$green;
}
.serverHeader__stripe--suspended {
background-color:$red;
}
.serverHeader__info {
flex: 1 0 auto;
padding:8px;
}
.serverHeader__stats {
background-color:$veryDarkBlue;
width:180px;
flex: 0 0 auto;
padding:15px;
border-radius:4px;
a:hover {
text-decoration:underline;
}
}
.serverHeader__usage {
background:green;
width:320px;
padding:15px;
margin-left:10px;
background-color:$veryDarkBlue;
flex: 0 0 auto;
border-radius:4px;
}
.serverHeader__title {
font-size:18px;
font-weight:700;
margin-bottom:10px;
}
.serverHeader__list {
line-height:1.5;
font-size:12px;
}
.serverHeader__list--ok {
color:$green;
}
.serverHeader__list--warning {
color:$orange;
}
.serverHeader__statsList {
line-height:1.8;
font-size:12px;
li {
padding-left:22px;
font-weight:300;
}
}
.serverHeader__stat-held {
background:image-url('icons/pause-white.svg') no-repeat 0 4px / 13px;
padding-left:22px;
}
.serverHeader__stat-queue {
background:image-url('icons/box-white.svg') no-repeat 0 4px / 13px;
}
.serverHeader__stat-size {
background:image-url('icons/size-white.svg') no-repeat 0 4px / 13px;
}
.serverHeader__stat-bounces {
background:image-url('icons/bats-white.svg') no-repeat 0 4px / 13px;
}
.serverHeader__usageTitle {
color:#566576;
font-size:12px;
font-weight: 600;
margin-bottom:5px;
}
.serverHeader__usageLine {
display:flex;
font-size:12px;
align-items:center;
}
.serverHeader__usageLine + .serverHeader__usageLine {
margin-top:6px;
}
.serverHeader__usageLineLabel {
flex: 1 0 auto;
}
.serverHeader__usageLineBar {
width:100px;
line-height:0;
}
.serverHeader__usageLineValue {
width:60px;
text-align:right;
font-weight:600;
}
.serverHeader__usageLineValueLarge {
width:300px;
text-align:right;
color:$subBlue;
b {
color:#fff;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_sidebar.scss
================================================
.sidebar {
width:250px;
background:#fff;
flex: 0 0 auto;
z-index:200;
box-shadow:5px 0 8px -2px rgba(0,0,0,0.1);
overflow-y: auto;
@include scrollbars(6px);
}
.sidebar__search {
background:$lightBlue;
border-bottom:1px solid #d1dcea;
padding:15px;
}
.sidebar__searchInput {
width:100%;
margin:0;
border:1px solid #e0e7f3;
border-radius:25px;
padding:6px 13px;
font:inherit;
font-size:12px;
font-weight:600;
color:$darkBlue;
background:image-url('icons/search.svg') #fff no-repeat 10px 7px / 17px;
padding-left:33px;
&::placeholder {
color:#98a5c0;
font-weight:300;
}
&:focus {
border-color:$blue;
}
}
.sidebar__placeholder {
margin:60px 20px;
text-align:center;
color:$subBlue;
font-size:18px;
line-height:1.5;
font-weight:300;
}
.sidebar__new {
text-align:center;
margin-top:15px;
margin-bottom:15px;
color:#999;
font-size:12px;
a:hover {
color:$green;
}
text-decoration: underline;
}
================================================
FILE: app/assets/stylesheets/application/components/_sidebar_server_list.scss
================================================
.sidebarServerList {
font-size:12px;
color:$darkBlue;
}
.sidebarServerList__item {
border-bottom:1px solid #e6ebf0;
}
.sidebarServerList__link {
display: block;
padding:15px 20px;
&:hover {
background-color:#f2f5f8;
}
}
.sidebarServerList__link.is-active {
background-color:#f2f5f8;
}
.sidebarServerList__item.is-highlighted .sidebarServerList__link {
background-color:#fffedd;
}
.sidebarServerList__mode {
float:right;
}
.sidebarServerList__title {
font-size:13px;
font-weight:600;
margin-bottom:5px;
}
.sidebarServerList__quantity {
color:$subBlue;
font-size:11px;
}
================================================
FILE: app/assets/stylesheets/application/components/_simple_pagination.scss
================================================
.simplePagination {
display:flex;
margin:25px 0;
justify-content:space-between;
font-size:12px;
}
.simplePagination__link {
color:$blue;
display:inline-block;
line-height:1.3;
border:1px solid lighten($subBlue, 25%);
padding:3px 10px;
text-decoration:none;
border-radius:4px;
background:#fff;
margin:0 2px;
&:hover {
border-color:$blue;
}
}
.simplePagination__next,
.simplePagination__previous,
.simplePagination__current {
width:33%;
}
.simplePagination__next {
text-align:right;
}
.simplePagination__current {
text-align:center;
color:$subBlue;
line-height:1.5;
}
.simplePagination__info {
font-weight:600;
}
================================================
FILE: app/assets/stylesheets/application/components/_site_content.scss
================================================
.siteContent {
align-items: stretch;
display: flex;
flex: 1 1 auto;
overflow: hidden;
}
.siteContent__main {
flex: 1 1 auto;
z-index:100;
overflow-y:scroll;
overflow-x:hidden;
}
.siteContent__footer {
border-top:1px solid #efefef;
margin-top:20px;
padding:25px;
display:flex;
}
================================================
FILE: app/assets/stylesheets/application/components/_site_header.scss
================================================
.siteHeader {
width:100%;
background:$blue;
flex: 0 0 auto;
color:#fff;
z-index:1000;
}
.siteHeader__inside {
display:flex;
padding:12px 16px;
align-items:center;
}
.siteHeader__remember {
background:$veryDarkBlue;
position:fixed;
top:20px;
right:20px;
padding:20px;
border-radius:4px;
color:#fff;
z-index:2000;
}
.siteHeader__rememberButtons {
margin-top:15px;
}
.siteHeader__rememberText {
line-height:1.5;
font-size:12px;
color:#999;
}
.siteHeader__rememberTextTitle {
font-weight:600;
color:#fff;
font-size:16px;
}
.siteHeader__logo {
display:block;
a {
margin:0;
font-size:14px;
font-weight:600;
display:block;
}
}
.siteHeader__version {
margin-left:5px;
color:#fff;
opacity:0.3;
font-size:12px;
}
.siteHeader__nav {
flex: 1 0 auto;
text-align:right;
font-size:12px;
display:flex;
justify-content:flex-end;
}
.siteHeader__navItem {
margin-left:18px;
}
.sideHeader__navItemLink {
text-decoration: underline;
opacity: 0.5;
}
.siteHeader__navLinkWithMenu {
background:image-url('icons/drop-arrow-white.svg') no-repeat right 6px / 8px;
padding-right:12px;
}
.siteHeader__navItem--user {
background:image-url('icons/user-white.svg') no-repeat 0 3px / 8px;
padding-left:13px;
}
.siteHeader__navItem--organization {
background:image-url('icons/organization-white.svg') no-repeat 0 2px / 12px;
padding-left:18px;
}
.siteHeader__subMenu {
position:absolute;
background:#fff;
z-index:1000;
color:$darkBlue;
text-align:left;
box-shadow:0 0 15px rgba(0,0,0,0.2);
border-radius:4px;
margin-left:-15px;
margin-top:-5px;
overflow:hidden;
display:none;
}
.siteHeader__navItem:hover .siteHeader__subMenu {
display:block;
}
.siteHeader__subMenuItem + .siteHeader__subMenuItem {
border-top:1px solid #e6ebf0;
}
.siteHeader__subMenuItem--header {
font-weight:600;
padding:5px 15px;
background:$lightBlue;
color:$blue;
}
.siteHeader__subMenuLink {
padding:10px 15px;
display:block;
&:hover {
background-color:#f2f5f8;
}
}
.siteHeader__subMenuItem--div {
border-top-width:2px !important;
}
================================================
FILE: app/assets/stylesheets/application/components/_spam_check_list.scss
================================================
.spamCheckList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.spamCheckList__item {
display:block;
background:#fff;
padding:15px;
align-items:center;
display:flex;
}
.spamCheckList__item:nth-child(even) {
background:none;
}
.spamCheckList__item + .spamCheckList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.spamCheckList__score {
width:130px;
font-size:20px;
font-weight:900;
text-align:center;
flex: 0 0 auto;
}
.spamCheckList__score--positive {
color:$green;
}
.spamCheckList__score--negative {
color:$red;
}
.spamCheckList__score--neutral {
color:$subBlue;
}
.spamCheckList__details {
flex: 1 1 auto;
}
.spamCheckList__code {
font-family:'Droid Sans Mono';
font-size:12px;
color:$subBlue;
margin-bottom:3px;
}
.spamCheckList__description {
line-height:1.5;
}
.spamCheckList__item--total + .spamCheckList__item{
border-top-width:2px;
border-top-color:$subBlue;
}
.spamCheckList__details--total {
}
================================================
FILE: app/assets/stylesheets/application/components/_starter_credit_pack.scss
================================================
.starterCreditPack {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
background:image-url('starter_pack.png') #fff no-repeat 25px 20px;
background-size:100px;
padding:25px 25px 20px 155px;
line-height:1.5;
}
.starterCreditPack__text {
margin-bottom:10px;
}
.starterCreditPack__nextRenew {
font-size:12px;
color:#999;
margin-left:5px;
vertical-align:-2px;
}
================================================
FILE: app/assets/stylesheets/application/components/_sub_page_box.scss
================================================
.subPageBox {
background:#fff;
border-radius:4px;
box-shadow:0 0 30px rgba(0,0,0,0.15);
width:300px;
margin:auto;
overflow:hidden;
border-top:5px solid $blue;
}
.subPageBox--wide {
width:500px;
}
.subPageBox__title {
background:$lightBlue;
border-bottom:1px solid #d1dcea;
padding:20px 25px;
color:$blue;
font-size:16px;
font-weight:600;
text-align: center;
}
.subPageBox__content {
padding:20px 25px;
}
.subPageBox__text {
color:$subBlue;
font-size:13px;
line-height:1.4;
margin-bottom:20px;
}
================================================
FILE: app/assets/stylesheets/application/components/_suppression_list.scss
================================================
.suppressionList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.suppressionList__item {
background:#fff;
padding:15px;
display:flex;
}
.suppressionList__item:nth-child(even) {
background:none;
}
.suppressionList__item + .suppressionList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.suppressionList__left {
flex: 1 1 auto;
}
.suppressionList__right {
flex: 0 0 auto;
text-align: right;
}
.suppressionList__timestamp {
color:#999;
font-size:12px;
}
.suppressionList__address {
font-weight:600;
margin-bottom:5px;
}
.suppressionList__reason {
color:#999;
}
================================================
FILE: app/assets/stylesheets/application/components/_suspension_box.scss
================================================
.suspensionBox {
background:#e2383a;
border-radius:4px;
color:#fff;
line-height:1.5;
padding:25px;
font-size:16px;
}
.suspensionBox__reason {
margin-top:5px;
font-size:14px;
opacity:0.7;
}
================================================
FILE: app/assets/stylesheets/application/components/_title_with_links.scss
================================================
.titleWithLinks {
display:flex;
color:$darkBlue;
align-items:center;
}
.titleWithLinks__title {
flex: 1 1 auto;
font-size:23px;
}
.titleWithLinks__links {
flex: 1 1 auto;
display:flex;
justify-content: flex-end;
li + li {
margin-left:25px;
}
}
.titleWithLinks__link {
text-decoration: underline;
&:hover {
color:$blue;
}
}
================================================
FILE: app/assets/stylesheets/application/components/_user_list.scss
================================================
.userList {
border-radius: 4px;
color: $darkBlue;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}
.userList__item {
display: block;
background: #fff;
padding: 15px;
display: flex;
align-items: center;
}
.userList__item:nth-child(even) {
background: none;
}
.userList__item+.userList__item {
border-top: 1px solid lighten(#ccd4e0, 10%);
}
.userList__details {
flex: 1 1 auto;
margin: 0 0;
}
.userList__actions {
flex: 0 0 auto;
width: 120px;
font-size: 12px;
line-height: 1.5;
color: #999;
a {
text-decoration: underline;
}
}
.userList__name {
font-weight: 600;
font-size: 16px;
margin-bottom: 3px;
}
.userList__owner {
vertical-align: 2px;
margin-left: 5px;
background-color: $orange;
}
.userList__pending {
vertical-align: 2px;
margin-left: 5px;
background-color: #ccc;
}
.userList__tag {
vertical-align: 2px;
margin-left: 3px;
}
.userList__revoke {
color: $red;
}
================================================
FILE: app/assets/stylesheets/application/components/_webhook_list.scss
================================================
.webhookList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.webhookList__item {
background:#fff;
padding:15px;
}
.webhookList__item:nth-child(even) {
background:none;
}
.webhookList__item + .webhookList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.webhookList__top {
display:flex;
align-items: center;
min-width:1px;
}
.webhookList__labels {
flex: 0 0 auto;
line-height:0;
margin-left:10px;
.label + .label {
margin-left:2px;
}
}
.webhookList__name {
font-weight:600;
flex: 1 1 auto;
overflow:hidden;
text-overflow:ellipsis;
line-height:1.4;
}
.webhookList__bottom {
display:flex;
margin-top:3px;
font-size:12px;
}
.webhookList__usageTime {
color:#999;
line-height:1.4;
flex: 1 1 auto;
}
.webhookList__links {
flex: 0 0 auto;
display:flex;
}
.webhookList__link {
a {
color:#999;
text-decoration: underline;
}
}
.webhookList__link + .webhookList__link {
margin-left:12px;
}
================================================
FILE: app/assets/stylesheets/application/components/_webhook_request_list.scss
================================================
.webhookRequestList {
border-radius:4px;
overflow:hidden;
box-shadow:0 0 10px rgba(0,0,0,0.2);
}
.webhookRequestList__item {
background:#fff;
}
.webhookRequestList__item:nth-child(even) {
background:none;
}
.webhookRequestList__item + .webhookRequestList__item {
border-top:1px solid lighten(#ccd4e0, 10%);
}
.webhookRequestList__link {
display:block;
padding:15px;
&:hover {
background:#f2f5f8 !important;
}
}
.webhookRequestList__top {
display:flex;
margin-bottom:6px;
font-size:12px;
}
.webhookRequestList__status {
margin-top:-1px;
margin-right:10px;
}
.webhookRequestList__time {
flex: 1 1 auto;
color:#999;
}
.webhookRequestList__event {
flex: 0 0 auto;
font-size:11px;
border:1px solid $subBlue;
color:$subBlue;
border-radius:3px;
padding:3px 6px;
margin-top:-2px;
}
.webhookRequestList__url {
flex: 1 1 auto;
overflow:hidden;
font-family:'Droid Sans Mono', fixed;
line-height:1.4;
text-overflow:ellipsis;
}
================================================
FILE: app/assets/stylesheets/application/elements/_bar.scss
================================================
.bar {
background:$darkBlue;
border-radius:10px;
display:inline-block;
height:5px;
width:100%;
overflow:hidden;
}
.bar__inner {
background:$blue;
display:inline-block;
border-radius:10px;
height:5px;
width:50%;
}
================================================
FILE: app/assets/stylesheets/application/elements/_button.scss
================================================
.button {
display: inline-block;
font: inherit;
border-radius: 4px;
appearance: none;
background: $blue;
color: #fff;
font-size: 14px !important;
margin: 0;
vertical-align: top;
padding: 6px 15px;
border: 2px solid transparent;
border-bottom: 2px solid darken($blue, 20%);
&:active {
background-color: darken($blue, 15%);
}
&:focus {
border-color: darken($blue, 15%);
background-color: lighten($blue, 5%);
}
&.is-spinning {
color: transparent;
background-repeat: no-repeat;
background-position: center center;
background-size: 25px;
background-image: image-url('button-spinner.gif');
}
}
.button--small {
font-size: 12px !important;
padding: 3px 10px;
border-width: 1px;
}
.button--positive {
background-color: $green;
border-bottom-color: darken($green, 15%);
&:active {
background-color: darken($green, 15%);
}
&:focus {
border-color: darken($green, 15%);
background-color: lighten($green, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-positive.gif');
}
}
.button--neutral {
background-color: #ccc;
border-bottom-color: darken(#ccc, 15%);
&:active {
background-color: darken(#ccc, 15%);
}
&:focus {
border-color: darken(#ccc, 15%);
background-color: lighten(#ccc, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-neutral.gif');
}
}
.button--danger {
background-color: $red;
border-bottom-color: darken($red, 15%);
&:active {
background-color: darken($red, 15%);
}
&:focus {
border-color: darken($red, 15%);
background-color: lighten($red, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-danger.gif');
}
}
.button--dark {
background-color: $darkBlue;
border-bottom-color: darken($darkBlue, 15%);
&:active {
background-color: darken($darkBlue, 15%);
}
&:focus {
border-color: darken($darkBlue, 15%);
background-color: lighten($darkBlue, 5%);
}
&.is-spinning {
background-image: image-url('button-spinner-dark.gif');
}
}
.button--full {
width: 100%;
text-align: center;
}
================================================
FILE: app/assets/stylesheets/application/elements/_code_block.scss
================================================
.codeBlock {
background:#909db0;
color:#fff;
padding:25px;
border-radius:4px;
}
.codeBlock--whitespace {
white-space:pre;
overflow-x:auto;
}
================================================
FILE: app/assets/stylesheets/application/elements/_input.scss
================================================
.input {
border:0;
padding:0;
margin:0;
background:#fff;
border:1px solid #e4e8ef;
padding:8px 10px;
font:inherit;
width:100%;
color:$darkBlue;
font-weight:600;
appearance:none;
border-radius:4px;
}
.input--onWhite {
background-color:$backgroundGrey;
}
.input:disabled, .input.is-disabled {
opacity:0.5;
}
.input:focus {
border-color:$blue;
background-color:#fff;
}
.input--danger {
color:$red;
border-width:2px;
border-color:lighten($red, 37%);
}
.input--area {
height:300px;
}
.input--smallArea {
height:120px;
}
.input--danger:focus {
border-color:$red;
color:$red;
background:#fff;
}
.input::placeholder {
color:#b5c0d0;
font-weight:400;
}
.input--select {
background: #fff image-url('icons/select-arrow.svg') right 12px top 50% / 16px 16px no-repeat;
cursor: pointer;
}
.input--code {
font-family:'Droid Sans Mono', fixed;
font-size:13px;
}
.inputPair {
display:flex;
justify-content: space-between;
.input {
width:49%;
}
}
================================================
FILE: app/assets/stylesheets/application/elements/_label.scss
================================================
.label {
display: inline-block;
background: #000;
color: #fff;
font-size: 9px;
text-transform: uppercase;
border-radius: 40px;
padding: 2px 6px;
line-height: 0.9;
}
.label--green {
background-color: $green;
}
.label--red {
background-color: $red;
}
.label--orange {
background-color: $orange;
}
.label--blue {
background-color: $blue;
}
.label--grey {
background-color: #999;
}
.label--turquoise {
background-color: $blue;
}
.label--purple {
background-color: $purple;
}
.label--large {
font-size: 11px;
padding: 4px 10px;
}
.label--serverStatus-live {
background-color: $green;
}
.label--serverStatus-development {
background-color: #636363;
}
.label--serverStatus-suspended {
background-color: $red;
}
.label--messageStatus-pending {
background-color: $subBlue;
}
.label--messageStatus-held {
background-color: #aaa;
}
.label--messageStatus-processed {
background-color: $green;
}
.label--messageStatus-sent {
background-color: $green;
}
.label--messageStatus-hard_fail {
background-color: $red;
}
.label--messageStatus-soft_fail {
background-color: $orange;
}
.label--messageStatus-bounced {
background-color: $red;
}
.label--messageStatus-hold_cancelled {
background-color: #ccc;
}
.label--credentialType-api {
background-color: $blue;
}
.label--credentialType-smtp {
background-color: $turquoise;
}
.label--credentialType-smtp_ip {
background-color: $orange;
}
.label--spamStatus-not_checked {
background: #aaa;
}
.label--spamStatus-spam {
background: $orange;
}
.label--spamStatus-not_spam {
background: $turquoise;
}
.label--http-status-2 {
background-color: $green;
}
.label--http-status-3 {
background-color: $orange;
}
.label--http-status-4,
.label--http-status-5 {
background-color: $red;
}
.domainList__ssl {
color: $green;
&:hover {
text-decoration: underline;
}
}
.domainList__ssl--disabled {
color: #999;
}
================================================
FILE: app/assets/stylesheets/application/elements/_misc.scss
================================================
.returnPathTag {
background:image-url('icons/return-path.svg') no-repeat 0 4px / 10px;
padding-left:14px;
}
.returnPathTag--inMessageHeader {
background-size:14px;
padding-left:18px;
}
.warningBox {
background-color:#fff8e4;
border:1px solid #c8bc9b;
padding:15px;
line-height:1.4;
}
================================================
FILE: app/assets/stylesheets/application/elements/_spam_range.scss
================================================
.spamRangeLabel {
font-size:12px;
text-align:right;
margin-top:7px;
}
.spamRange {
-webkit-appearance: none; /* Hides the slider so that custom slider can be made */
width: 100%; /* Specific width is required for Firefox. */
background: transparent; /* Otherwise white in Chrome */
&:disabled {
opacity:0.5;
}
}
.spamRange::-webkit-slider-thumb {
-webkit-appearance: none;
}
.spamRange:focus {
outline: none; /* Removes the blue border. You should probably do some kind of focus styling for accessibility reasons though. */
}
.spamRange::-ms-track {
width: 100%;
cursor: pointer;
/* Hides the slider so custom styles can be added */
background: transparent;
border-color: transparent;
color: transparent;
}
/* Special styling for WebKit/Blink */
.spamRange::-webkit-slider-thumb {
-webkit-appearance: none;
border: 2px solid #2b2e32;
height:25px;
width:25px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
margin-top: -7px; /* You need to specify a margin in Chrome, but in Firefox and IE it is automatic */
}
/* All the same stuff for Firefox */
.spamRange::-moz-range-thumb {
border: 2px solid #2b2e32;
height:25px;
width:25px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
}
/* All the same stuff for IE */
.spamRange::-ms-thumb {
border: 2px solid #2b2e32;
height:25px;
width:25px;
border-radius: 50%;
background: #ffffff;
cursor: pointer;
}
//
// Track
//
.spamRange::-webkit-slider-runnable-track {
width: 100%;
height: 12px;
cursor: pointer;
border-radius:30px;
background: #3ff990; /* Old browsers */
background: -moz-linear-gradient(left, #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */
}
.spamRange::-moz-range-track {
width: 100%;
height: 12px;
cursor: pointer;
border-radius:30px;
background: #3ff990; /* Old browsers */
background: -moz-linear-gradient(left, #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */
}
.spamRange::-ms-track {
width: 100%;
height: 12px;
cursor: pointer;
border-radius:30px;
background: transparent;
border-color: transparent;
background: #3ff990; /* Old browsers */
background: -moz-linear-gradient(left, #3ff990 0%, #197ec9 47%, #6c5c8b 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #3ff990 0%,#197ec9 47%,#6c5c8b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3ff990', endColorstr='#6c5c8b',GradientType=1 ); /* IE6-9 */
}
.spamRange--hot::-webkit-slider-runnable-track {
background: #1688d0; /* Old browsers */
background: -moz-linear-gradient(left, #1688d0 0%, #fa141b 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */
}
.spamRange--hot::-moz-range-track {
background: #1688d0; /* Old browsers */
background: -moz-linear-gradient(left, #1688d0 0%, #fa141b 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */
}
.spamRange--hot::-ms-track {
background: #1688d0; /* Old browsers */
background: -moz-linear-gradient(left, #1688d0 0%, #fa141b 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #1688d0 0%,#fa141b 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #1688d0 0%,#fa141b 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1688d0', endColorstr='#fa141b',GradientType=1 ); /* IE6-9 */
}
.spamRange--blueGreen::-webkit-slider-runnable-track {
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */
background: #146dd2; /* Old browsers */
background: -moz-linear-gradient(left, #146dd2 0%, #7cc546 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */
}
.spamRange--blueGreen::-moz-range-track {
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */
background: #146dd2; /* Old browsers */
background: -moz-linear-gradient(left, #146dd2 0%, #7cc546 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */
}
.spamRange--blueGreen::-ms-track {
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#146dd2+0,7cc546+100 */
background: #146dd2; /* Old browsers */
background: -moz-linear-gradient(left, #146dd2 0%, #7cc546 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #146dd2 0%,#7cc546 100%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #146dd2 0%,#7cc546 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#146dd2', endColorstr='#7cc546',GradientType=1 ); /* IE6-9 */
}
================================================
FILE: app/assets/stylesheets/application/global/_fonts.scss
================================================
@font-face {
font-family: "Droid Sans Mono";
src: font-url("DroidSansMono.eot");
src: font-url("DroidSansMono.eot?#iefix") format("embedded-opentype"),
font-url("DroidSansMono.woff") format("woff"),
font-url("DroidSansMono.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: font-url("SourceSansPro-Light.eot");
src: font-url("SourceSansPro-Light.eot?#iefix") format("embedded-opentype"),
font-url("SourceSansPro-Light.woff") format("woff"),
font-url("SourceSansPro-Light.ttf") format("truetype");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: font-url("SourceSansPro-Regular.eot");
src: font-url("SourceSansPro-Regular.eot?#iefix") format("embedded-opentype"),
font-url("SourceSansPro-Regular.woff") format("woff"),
font-url("SourceSansPro-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: font-url("SourceSansPro-Semibold.eot");
src: font-url("SourceSansPro-Semibold.eot?#iefix") format("embedded-opentype"),
font-url("SourceSansPro-Semibold.woff") format("woff"),
font-url("SourceSansPro-Semibold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: font-url("SourceSansPro-Bold.eot");
src: font-url("SourceSansPro-Bold.eot?#iefix") format("embedded-opentype"),
font-url("SourceSansPro-Bold.woff") format("woff"),
font-url("SourceSansPro-Bold.ttf") format("truetype");
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: "Source Sans Pro";
src: font-url("SourceSansPro-Black.eot");
src: font-url("SourceSansPro-Black.eot?#iefix") format("embedded-opentype"),
font-url("SourceSansPro-Black.woff") format("woff"),
font-url("SourceSansPro-Black.ttf") format("truetype");
font-weight: 900;
font-style: normal;
}
================================================
FILE: app/assets/stylesheets/application/global/_mixins.scss
================================================
@mixin scrollbars($size: 6px, $thumb: #979ea6, $track: #efefef) {
&::-webkit-scrollbar {
height: $size; // Horizontal Scrollbars
width: $size; // Vertical Scrollbars
}
&::-webkit-scrollbar-thumb {
background: $thumb;
}
&::-webkit-scrollbar-track {
background: $track;
}
}
================================================
FILE: app/assets/stylesheets/application/global/_reset.scss
================================================
html, body, body div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, figure, footer, header, hgroup, menu, nav, section, time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
outline: 0;
font-weight: normal;
font-size: 100%;
letter-spacing:0;
vertical-align: baseline;
background: transparent;
}
span {
font-weight:inherit;
}
article, aside, figure, footer, header, hgroup, nav, section {display: block;}
img,object,embed {max-width: 100%;}
ul {list-style: none;}
blockquote, q {quotes: none;}
b,strong { font-weight:bold;}
strong.semi { font-weight:600;}
blockquote:before, blockquote:after, q:before, q:after {content: ''; content: none;}
a {margin: 0; padding: 0; font-size: 100%; vertical-align: baseline; background: transparent; color:inherit; text-decoration: none; line-height:1; margin:0 }
del {text-decoration: line-through;}
abbr[title], dfn[title] {border-bottom: 1px dotted #000; cursor: help;}
/* tables still need cellspacing="0" in the markup */
table {border-collapse: collapse; border-spacing: 0;}
th {font-weight: bold; vertical-align: bottom;}
td {font-weight: normal; vertical-align: top;}
hr {display: block; height: 1px; border: 0; border-top: 3px solid #ddd; margin:0; padding: 0;}
input, select {vertical-align: middle;}
pre {
white-space: pre; /* CSS2 */
white-space: pre-wrap; /* CSS 2.1 */
white-space: pre-line; /* CSS 3 (and 2.1 as well, actually) */
word-wrap: break-word; /* IE */
}
input[type="radio"] {vertical-align: text-bottom;}
input[type="checkbox"] {vertical-align: bottom; *vertical-align: baseline;}
.ie6 input {vertical-align: text-bottom;}
select, input, textarea {font: 99% sans-serif;}
table {font: inherit;}
/* Accessible focus treatment
people.opera.com/patrickl/experiments/keyboard/test */
a:hover, a:active {outline: none;}
small {font-size: 85%;}
strong, th {font-weight: bold;}
td, td img {vertical-align: top;}
/* Make sure sup and sub don't screw with your line-heights
gist.github.com/413930 */
sub, sup {font-size: 75%; line-height: 0; position: relative;}
sup {top: -0.5em;}
sub {bottom: -0.25em;}
/* standardize any monospaced elements */
pre, code, kbd, samp {font-family: 'Droid Sans Mono', fixed;}
/* hand cursor on clickable elements */
label,
input[type=button],
input[type=submit],
button {
cursor: pointer;
}
button, input, select, textarea {
margin: 0;
}
button {
width: auto;
overflow: visible;
appearance: none;
}
select, input, textarea, a, button {
outline: none;
}
*, *:before, *:after {
box-sizing: border-box;
}
address {
font-style: normal;
}
th {
font-weight: initial;
text-align: left;
}
img {
border: 0;
}
================================================
FILE: app/assets/stylesheets/application/global/_utility.scss
================================================
.u-margin {
margin-bottom:25px;
}
.u-margin-half {
margin-bottom:10px;
}
.u-center {
text-align:center;
}
.u-green {
color:$green;
}
.u-orange {
color:$orange;
}
.u-grey {
color:#999;
}
.u-red {
color:$red;
}
.u-bold {
font-weight:600;
}
.u-link {
text-decoration: underline;
}
.is-hidden {
display:none;
}
================================================
FILE: app/assets/stylesheets/application/global/_variables.scss
================================================
$backgroundGrey: #fafafa;
$blue: #0e69d5;
$darkBlue: #3c4249;
$veryDarkBlue: #2b2e32;
$lightBlue: #eaf3fe;
$subBlue: #909db0;
$red: #e2383a;
$green: #76c83b;
$orange: #e8581f;
$turquoise: #4ac7c5;
$purple: #6145b2;
@mixin clearfix {
&:after {
clear: both;
content: " ";
display: table;
}
}
@mixin noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
================================================
FILE: app/assets/stylesheets/application/vendor/_chartist.scss
================================================
.ct-label {
fill: rgba(0, 0, 0, 0.4);
color: rgba(0, 0, 0, 0.4);
font-size: 0.75rem;
line-height: 1; }
.ct-chart-line .ct-label,
.ct-chart-bar .ct-label {
display: block;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex; }
.ct-label.ct-horizontal.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-label.ct-horizontal.ct-end {
-webkit-box-align: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-label.ct-vertical.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-end;
-webkit-justify-content: flex-end;
-ms-flex-pack: flex-end;
justify-content: flex-end;
text-align: right;
text-anchor: end; }
.ct-label.ct-vertical.ct-end {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-chart-bar .ct-label.ct-horizontal.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
text-anchor: start; }
.ct-chart-bar .ct-label.ct-horizontal.ct-end {
-webkit-box-align: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
text-anchor: start; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end {
-webkit-box-align: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start {
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: flex-end;
-webkit-justify-content: flex-end;
-ms-flex-pack: flex-end;
justify-content: flex-end;
text-align: right;
text-anchor: end; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end {
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: end; }
.ct-grid {
stroke: rgba(0, 0, 0, 0.2);
stroke-width: 1px;
stroke-dasharray: 2px; }
.ct-point {
stroke-width: 10px;
stroke-linecap: round; }
.ct-line {
fill: none;
stroke-width: 4px; }
.ct-area {
stroke: none;
fill-opacity: 0.1; }
.ct-bar {
fill: none;
stroke-width: 10px; }
.ct-slice-donut {
fill: none;
stroke-width: 60px; }
.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {
stroke: #d70206; }
.ct-series-a .ct-slice-pie, .ct-series-a .ct-area {
fill: #d70206; }
.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {
stroke: #f05b4f; }
.ct-series-b .ct-slice-pie, .ct-series-b .ct-area {
fill: #f05b4f; }
.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {
stroke: #f4c63d; }
.ct-series-c .ct-slice-pie, .ct-series-c .ct-area {
fill: #f4c63d; }
.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {
stroke: #d17905; }
.ct-series-d .ct-slice-pie, .ct-series-d .ct-area {
fill: #d17905; }
.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {
stroke: #453d3f; }
.ct-series-e .ct-slice-pie, .ct-series-e .ct-area {
fill: #453d3f; }
.ct-series-f .ct-point, .ct-series-f .ct-line, .ct-series-f .ct-bar, .ct-series-f .ct-slice-donut {
stroke: #59922b; }
.ct-series-f .ct-slice-pie, .ct-series-f .ct-area {
fill: #59922b; }
.ct-series-g .ct-point, .ct-series-g .ct-line, .ct-series-g .ct-bar, .ct-series-g .ct-slice-donut {
stroke: #0544d3; }
.ct-series-g .ct-slice-pie, .ct-series-g .ct-area {
fill: #0544d3; }
.ct-series-h .ct-point, .ct-series-h .ct-line, .ct-series-h .ct-bar, .ct-series-h .ct-slice-donut {
stroke: #6b0392; }
.ct-series-h .ct-slice-pie, .ct-series-h .ct-area {
fill: #6b0392; }
.ct-series-i .ct-point, .ct-series-i .ct-line, .ct-series-i .ct-bar, .ct-series-i .ct-slice-donut {
stroke: #f05b4f; }
.ct-series-i .ct-slice-pie, .ct-series-i .ct-area {
fill: #f05b4f; }
.ct-series-j .ct-point, .ct-series-j .ct-line, .ct-series-j .ct-bar, .ct-series-j .ct-slice-donut {
stroke: #dda458; }
.ct-series-j .ct-slice-pie, .ct-series-j .ct-area {
fill: #dda458; }
.ct-series-k .ct-point, .ct-series-k .ct-line, .ct-series-k .ct-bar, .ct-series-k .ct-slice-donut {
stroke: #eacf7d; }
.ct-series-k .ct-slice-pie, .ct-series-k .ct-area {
fill: #eacf7d; }
.ct-series-l .ct-point, .ct-series-l .ct-line, .ct-series-l .ct-bar, .ct-series-l .ct-slice-donut {
stroke: #86797d; }
.ct-series-l .ct-slice-pie, .ct-series-l .ct-area {
fill: #86797d; }
.ct-series-m .ct-point, .ct-series-m .ct-line, .ct-series-m .ct-bar, .ct-series-m .ct-slice-donut {
stroke: #b2c326; }
.ct-series-m .ct-slice-pie, .ct-series-m .ct-area {
fill: #b2c326; }
.ct-series-n .ct-point, .ct-series-n .ct-line, .ct-series-n .ct-bar, .ct-series-n .ct-slice-donut {
stroke: #6188e2; }
.ct-series-n .ct-slice-pie, .ct-series-n .ct-area {
fill: #6188e2; }
.ct-series-o .ct-point, .ct-series-o .ct-line, .ct-series-o .ct-bar, .ct-series-o .ct-slice-donut {
stroke: #a748ca; }
.ct-series-o .ct-slice-pie, .ct-series-o .ct-area {
fill: #a748ca; }
.ct-square {
display: block;
position: relative;
width: 100%; }
.ct-square:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 100%; }
.ct-square:after {
content: "";
display: table;
clear: both; }
.ct-square > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-second {
display: block;
position: relative;
width: 100%; }
.ct-minor-second:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 93.75%; }
.ct-minor-second:after {
content: "";
display: table;
clear: both; }
.ct-minor-second > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-second {
display: block;
position: relative;
width: 100%; }
.ct-major-second:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 88.8888888889%; }
.ct-major-second:after {
content: "";
display: table;
clear: both; }
.ct-major-second > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-third {
display: block;
position: relative;
width: 100%; }
.ct-minor-third:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 83.3333333333%; }
.ct-minor-third:after {
content: "";
display: table;
clear: both; }
.ct-minor-third > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-third {
display: block;
position: relative;
width: 100%; }
.ct-major-third:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 80%; }
.ct-major-third:after {
content: "";
display: table;
clear: both; }
.ct-major-third > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-perfect-fourth {
display: block;
position: relative;
width: 100%; }
.ct-perfect-fourth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 75%; }
.ct-perfect-fourth:after {
content: "";
display: table;
clear: both; }
.ct-perfect-fourth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-perfect-fifth {
display: block;
position: relative;
width: 100%; }
.ct-perfect-fifth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 66.6666666667%; }
.ct-perfect-fifth:after {
content: "";
display: table;
clear: both; }
.ct-perfect-fifth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-sixth {
display: block;
position: relative;
width: 100%; }
.ct-minor-sixth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 62.5%; }
.ct-minor-sixth:after {
content: "";
display: table;
clear: both; }
.ct-minor-sixth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-golden-section {
display: block;
position: relative;
width: 100%; }
.ct-golden-section:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 61.804697157%; }
.ct-golden-section:after {
content: "";
display: table;
clear: both; }
.ct-golden-section > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-sixth {
display: block;
position: relative;
width: 100%; }
.ct-major-sixth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 60%; }
.ct-major-sixth:after {
content: "";
display: table;
clear: both; }
.ct-major-sixth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-seventh {
display: block;
position: relative;
width: 100%; }
.ct-minor-seventh:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 56.25%; }
.ct-minor-seventh:after {
content: "";
display: table;
clear: both; }
.ct-minor-seventh > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-seventh {
display: block;
position: relative;
width: 100%; }
.ct-major-seventh:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 53.3333333333%; }
.ct-major-seventh:after {
content: "";
display: table;
clear: both; }
.ct-major-seventh > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-octave {
display: block;
position: relative;
width: 100%; }
.ct-octave:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 50%; }
.ct-octave:after {
content: "";
display: table;
clear: both; }
.ct-octave > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-tenth {
display: block;
position: relative;
width: 100%; }
.ct-major-tenth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 40%; }
.ct-major-tenth:after {
content: "";
display: table;
clear: both; }
.ct-major-tenth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-eleventh {
display: block;
position: relative;
width: 100%; }
.ct-major-eleventh:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 37.5%; }
.ct-major-eleventh:after {
content: "";
display: table;
clear: both; }
.ct-major-eleventh > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-twelfth {
display: block;
position: relative;
width: 100%; }
.ct-major-twelfth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 33.3333333333%; }
.ct-major-twelfth:after {
content: "";
display: table;
clear: both; }
.ct-major-twelfth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-double-octave {
display: block;
position: relative;
width: 100%; }
.ct-double-octave:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 25%; }
.ct-double-octave:after {
content: "";
display: table;
clear: both; }
.ct-double-octave > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
/*# sourceMappingURL=chartist.css.map */
================================================
FILE: app/controllers/address_endpoints_controller.rb
================================================
# frozen_string_literal: true
class AddressEndpointsController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @address_endpoint = @server.address_endpoints.find_by_uuid!(params[:id]) }
def index
@address_endpoints = @server.address_endpoints.order(:address).to_a
end
def new
@address_endpoint = @server.address_endpoints.build
end
def create
@address_endpoint = @server.address_endpoints.build(safe_params)
if @address_endpoint.save
flash[:notice] = params[:return_notice] if params[:return_notice].present?
redirect_to_with_json [:return_to, [organization, @server, :address_endpoints]]
else
render_form_errors "new", @address_endpoint
end
end
def update
if @address_endpoint.update(safe_params)
redirect_to_with_json [organization, @server, :address_endpoints]
else
render_form_errors "edit", @address_endpoint
end
end
def destroy
@address_endpoint.destroy
redirect_to_with_json [organization, @server, :address_endpoints]
end
private
def safe_params
params.require(:address_endpoint).permit(:address)
end
end
================================================
FILE: app/controllers/application_controller.rb
================================================
# frozen_string_literal: true
require "authie/session"
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :login_required
before_action :set_timezone
rescue_from Authie::Session::InactiveSession, with: :auth_session_error
rescue_from Authie::Session::ExpiredSession, with: :auth_session_error
rescue_from Authie::Session::BrowserMismatch, with: :auth_session_error
private
def login_required
return if logged_in?
redirect_to login_path(return_to: request.fullpath)
end
def admin_required
if logged_in?
unless current_user.admin?
render plain: "Not permitted"
end
else
redirect_to login_path(return_to: request.fullpath)
end
end
def require_organization_owner
return if organization.owner == current_user
redirect_to organization_root_path(organization), alert: "This page can only be accessed by the organization's owner (#{organization.owner.name})"
end
def auth_session_error(exception)
Rails.logger.info "AuthSessionError: #{exception.class}: #{exception.message}"
redirect_to login_path(return_to: request.fullpath)
end
def page_title
@page_title ||= ["Postal"]
end
helper_method :page_title
def redirect_to_with_return_to(url, *args)
redirect_to url_with_return_to(url), *args
end
def set_timezone
Time.zone = logged_in? ? current_user.time_zone : "UTC"
end
def append_info_to_payload(payload)
super
payload[:ip] = request.ip
payload[:user] = logged_in? ? current_user.id : nil
end
def url_with_return_to(url)
if params[:return_to].blank? || !params[:return_to].starts_with?("/")
url_for(url)
else
params[:return_to]
end
end
def redirect_to_with_json(url, flash_messages = {})
if url.is_a?(Array) && url[0] == :return_to
url = url_with_return_to(url[1])
else
url = url_for(url)
end
flash_messages.each do |key, value|
flash[key] = value
end
respond_to do |wants|
wants.html { redirect_to url }
wants.json { render json: { redirect_to: url } }
end
end
def render_form_errors(action_name, object)
respond_to do |wants|
wants.html { render action_name }
wants.json { render json: { form_errors: object.errors.map(&:full_message) }, status: :unprocessable_entity }
end
end
def flash_now(type, message, options = {})
respond_to do |wants|
wants.html do
flash.now[type] = message
if options[:render_action]
render options[:render_action]
end
end
wants.json { render json: { flash: { type => message } } }
end
end
def login(user)
if logged_in?
auth_session.invalidate!
reset_session
end
create_auth_session(user)
@current_user = user
end
end
================================================
FILE: app/controllers/concerns/.keep
================================================
================================================
FILE: app/controllers/concerns/within_organization.rb
================================================
# frozen_string_literal: true
module WithinOrganization
extend ActiveSupport::Concern
included do
helper_method :organization
before_action :add_organization_to_page_title
end
private
def organization
@organization ||= current_user.organizations_scope.find_by_permalink!(params[:org_permalink])
end
def add_organization_to_page_title
page_title << organization.name
end
end
================================================
FILE: app/controllers/credentials_controller.rb
================================================
# frozen_string_literal: true
class CredentialsController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @credential = @server.credentials.find_by_uuid!(params[:id]) }
def index
@credentials = @server.credentials.order(:name).to_a
end
def new
@credential = @server.credentials.build
end
def create
@credential = @server.credentials.build(params.require(:credential).permit(:type, :name, :key, :hold))
if @credential.save
redirect_to_with_json [organization, @server, :credentials]
else
render_form_errors "new", @credential
end
end
def update
if @credential.update(params.require(:credential).permit(:name, :key, :hold))
redirect_to_with_json [organization, @server, :credentials]
else
render_form_errors "edit", @credential
end
end
def destroy
@credential.destroy
redirect_to_with_json [organization, @server, :credentials]
end
end
================================================
FILE: app/controllers/domains_controller.rb
================================================
# frozen_string_literal: true
class DomainsController < ApplicationController
include WithinOrganization
before_action do
if params[:server_id]
@server = organization.servers.present.find_by_permalink!(params[:server_id])
params[:id] && @domain = @server.domains.find_by_uuid!(params[:id])
else
params[:id] && @domain = organization.domains.find_by_uuid!(params[:id])
end
end
def index
if @server
@domains = @server.domains.order(:name).to_a
else
@domains = organization.domains.order(:name).to_a
end
end
def new
@domain = @server ? @server.domains.build : organization.domains.build
end
def create
scope = @server ? @server.domains : organization.domains
@domain = scope.build(params.require(:domain).permit(:name, :verification_method))
if current_user.admin?
@domain.verification_method = "DNS"
@domain.verified_at = Time.now
end
if @domain.save
if @domain.verified?
redirect_to_with_json [:setup, organization, @server, @domain]
else
redirect_to_with_json [:verify, organization, @server, @domain]
end
else
render_form_errors "new", @domain
end
end
def destroy
@domain.destroy
redirect_to_with_json [organization, @server, :domains]
end
def verify
if @domain.verified?
redirect_to [organization, @server, :domains], alert: "#{@domain.name} has already been verified."
return
end
return unless request.post?
case @domain.verification_method
when "DNS"
if @domain.verify_with_dns
redirect_to_with_json [:setup, organization, @server, @domain], notice: "#{@domain.name} has been verified successfully. You now need to configure your DNS records."
else
respond_to do |wants|
wants.html { flash.now[:alert] = "We couldn't verify your domain. Please double check you've added the TXT record correctly." }
wants.json { render json: { flash: { alert: "We couldn't verify your domain. Please double check you've added the TXT record correctly." } } }
end
end
when "Email"
if params[:code]
if @domain.verification_token == params[:code].to_s.strip
@domain.mark_as_verified
redirect_to_with_json [:setup, organization, @server, @domain], notice: "#{@domain.name} has been verified successfully. You now need to configure your DNS records."
else
respond_to do |wants|
wants.html { flash.now[:alert] = "Invalid verification code. Please check and try again." }
wants.json { render json: { flash: { alert: "Invalid verification code. Please check and try again." } } }
end
end
elsif params[:email_address].present?
raise Postal::Error, "Invalid email address" unless @domain.verification_email_addresses.include?(params[:email_address])
AppMailer.verify_domain(@domain, params[:email_address], current_user).deliver
if @domain.owner.is_a?(Server)
redirect_to_with_json verify_organization_server_domain_path(organization, @server, @domain, email_address: params[:email_address])
else
redirect_to_with_json verify_organization_domain_path(organization, @domain, email_address: params[:email_address])
end
end
end
end
def setup
return if @domain.verified?
redirect_to [:verify, organization, @server, @domain], alert: "You can't set up DNS for this domain until it has been verified."
end
def check
if @domain.check_dns(:manual)
redirect_to_with_json [organization, @server, :domains], notice: "Your DNS records for #{@domain.name} look good!"
else
redirect_to_with_json [:setup, organization, @server, @domain], alert: "There seems to be something wrong with your DNS records. Check below for information."
end
end
end
================================================
FILE: app/controllers/help_controller.rb
================================================
# frozen_string_literal: true
class HelpController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.find_by_permalink!(params[:server_id]) }
def outgoing
@credentials = @server.credentials.group_by(&:type)
end
end
================================================
FILE: app/controllers/http_endpoints_controller.rb
================================================
# frozen_string_literal: true
class HTTPEndpointsController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @http_endpoint = @server.http_endpoints.find_by_uuid!(params[:id]) }
def index
@http_endpoints = @server.http_endpoints.order(:name).to_a
end
def new
@http_endpoint = @server.http_endpoints.build
end
def create
@http_endpoint = @server.http_endpoints.build(safe_params)
if @http_endpoint.save
flash[:notice] = params[:return_notice] if params[:return_notice].present?
redirect_to_with_json [:return_to, [organization, @server, :http_endpoints]]
else
render_form_errors "new", @http_endpoint
end
end
def update
if @http_endpoint.update(safe_params)
redirect_to_with_json [organization, @server, :http_endpoints]
else
render_form_errors "edit", @http_endpoint
end
end
def destroy
@http_endpoint.destroy
redirect_to_with_json [organization, @server, :http_endpoints]
end
private
def safe_params
params.require(:http_endpoint).permit(:name, :url, :encoding, :format, :strip_replies, :include_attachments, :timeout)
end
end
================================================
FILE: app/controllers/ip_addresses_controller.rb
================================================
# frozen_string_literal: true
class IPAddressesController < ApplicationController
before_action :admin_required
before_action { @ip_pool = IPPool.find_by_uuid!(params[:ip_pool_id]) }
before_action { params[:id] && @ip_address = @ip_pool.ip_addresses.find(params[:id]) }
def new
@ip_address = @ip_pool.ip_addresses.build
end
def create
@ip_address = @ip_pool.ip_addresses.build(safe_params)
if @ip_address.save
redirect_to_with_json [:edit, @ip_pool]
else
render_form_errors "new", @ip_address
end
end
def update
if @ip_address.update(safe_params)
redirect_to_with_json [:edit, @ip_pool]
else
render_form_errors "edit", @ip_address
end
end
def destroy
@ip_address.destroy
redirect_to_with_json [:edit, @ip_pool]
end
private
def safe_params
params.require(:ip_address).permit(:ipv4, :ipv6, :hostname, :priority)
end
end
================================================
FILE: app/controllers/ip_pool_rules_controller.rb
================================================
# frozen_string_literal: true
class IPPoolRulesController < ApplicationController
include WithinOrganization
before_action do
if params[:server_id]
@server = organization.servers.present.find_by_permalink!(params[:server_id])
params[:id] && @ip_pool_rule = @server.ip_pool_rules.find_by_uuid!(params[:id])
else
params[:id] && @ip_pool_rule = organization.ip_pool_rules.find_by_uuid!(params[:id])
end
end
def index
if @server
@ip_pool_rules = @server.ip_pool_rules
else
@ip_pool_rules = organization.ip_pool_rules
end
end
def new
@ip_pool_rule = @server ? @server.ip_pool_rules.build : organization.ip_pool_rules.build
end
def create
scope = @server ? @server.ip_pool_rules : organization.ip_pool_rules
@ip_pool_rule = scope.build(safe_params)
if @ip_pool_rule.save
redirect_to_with_json [organization, @server, :ip_pool_rules]
else
render_form_errors "new", @ip_pool_rule
end
end
def update
if @ip_pool_rule.update(safe_params)
redirect_to_with_json [organization, @server, :ip_pool_rules]
else
render_form_errors "edit", @ip_pool_rule
end
end
def destroy
@ip_pool_rule.destroy
redirect_to_with_json [organization, @server, :ip_pool_rules]
end
private
def safe_params
params.require(:ip_pool_rule).permit(:from_text, :to_text, :ip_pool_id)
end
end
================================================
FILE: app/controllers/ip_pools_controller.rb
================================================
# frozen_string_literal: true
class IPPoolsController < ApplicationController
before_action :admin_required
before_action { params[:id] && @ip_pool = IPPool.find_by_uuid!(params[:id]) }
def index
@ip_pools = IPPool.order(:name).to_a
end
def new
@ip_pool = IPPool.new
end
def create
@ip_pool = IPPool.new(safe_params)
if @ip_pool.save
redirect_to_with_json [:edit, @ip_pool], notice: "IP Pool has been added successfully. You can now add IP addresses to it."
else
render_form_errors "new", @ip_pool
end
end
def update
if @ip_pool.update(safe_params)
redirect_to_with_json [:edit, @ip_pool], notice: "IP Pool has been updated."
else
render_form_errors "edit", @ip_pool
end
end
def destroy
@ip_pool.destroy
redirect_to_with_json :ip_pools, notice: "IP pool has been removed successfully."
rescue ActiveRecord::DeleteRestrictionError
redirect_to_with_json [:edit, @ip_pool], alert: "IP pool cannot be removed because it still has associated addresses or servers."
end
private
def safe_params
params.require(:ip_pool).permit(:name, :default)
end
end
================================================
FILE: app/controllers/legacy_api/base_controller.rb
================================================
# frozen_string_literal: true
module LegacyAPI
# The Legacy API is the Postal v1 API which existed from the start with main
# aim of allowing e-mails to sent over HTTP rather than SMTP. The API itself
# did not feature much functionality. This API was implemented using Moonrope
# which was a self documenting API tool, however, is now no longer maintained.
# In light of that, these controllers now implement the same functionality as
# the original Moonrope API without the actual requirement to use any of the
# Moonrope components.
#
# Important things to note about the API:
#
# * Moonrope allow params to be provided as JSON in the body of the request
# along with the application/json content type. It also allowed for params
# to be sent in the 'params' parameter when using the
# application/x-www-form-urlencoded content type. Both methods are supported.
#
# * Authentication is performed using a X-Server-API-Key variable.
#
# * The method used to make the request is not important. Most clients use POST
# but other methods should be supported. The routing for this legacvy
# API supports GET, POST, PUT and PATCH.
#
# * The status code for responses will always be 200 OK. The actual status of
# a request is determined by the value of the 'status' attribute in the
# returned JSON.
class BaseController < ActionController::Base
skip_before_action :set_browser_id
skip_before_action :verify_authenticity_token
before_action :start_timer
before_action :authenticate_as_server
private
# The Moonrope API spec allows for parameters to be provided in the body
# along with the application/json content type or they can be provided,
# as JSON, in the 'params' parameter when used with the
# application/x-www-form-urlencoded content type. This legacy API needs
# support both options for maximum compatibility.
#
# @return [Hash]
def api_params
if request.headers["content-type"] =~ /\Aapplication\/json/
return params.to_unsafe_hash
end
if params["params"].present?
return JSON.parse(params["params"])
end
{}
end
# The API returns a length of time to complete a request. We'll start
# a timer when the request starts and then use this method to calculate
# the time taken to complete the request.
#
# @return [void]
def start_timer
@start_time = Time.now.to_f
end
# The only method available to authenticate to the legacy API is using a
# credential from the server itself. This method will attempt to find
# that credential from the X-Server-API-Key header and will set the
# current_credential instance variable if a token is valid. Otherwise it
# will render an error to halt execution.
#
# @return [void]
def authenticate_as_server
key = request.headers["X-Server-API-Key"]
if key.blank?
render_error "AccessDenied",
message: "Must be authenticated as a server."
return
end
credential = Credential.where(type: "API", key: key).first
if credential.nil?
render_error "InvalidServerAPIKey",
message: "The API token provided in X-Server-API-Key was not valid.",
token: key
return
end
if credential.server.suspended?
render_error "ServerSuspended"
return
end
credential.use
@current_credential = credential
end
# Render a successful response to the client
#
# @param [Hash] data
# @return [void]
def render_success(data)
render json: { status: "success",
time: (Time.now.to_f - @start_time).round(3),
flags: {},
data: data }
end
# Render an error response to the client
#
# @param [String] code
# @param [Hash] data
# @return [void]
def render_error(code, data = {})
render json: { status: "error",
time: (Time.now.to_f - @start_time).round(3),
flags: {},
data: data.merge(code: code) }
end
# Render a parameter error response to the client
#
# @param [String] message
# @return [void]
def render_parameter_error(message)
render json: { status: "parameter-error",
time: (Time.now.to_f - @start_time).round(3),
flags: {},
data: { message: message } }
end
end
end
================================================
FILE: app/controllers/legacy_api/messages_controller.rb
================================================
# frozen_string_literal: true
module LegacyAPI
class MessagesController < BaseController
# Returns details about a given message
#
# URL: /api/v1/messages/message
#
# Parameters: id => REQ: The ID of the message
# _expansions => An array of types of details t
# to return
#
# Response: A hash containing message information
# OR an error if the message does not exist.
#
def message
if api_params["id"].blank?
render_parameter_error "`id` parameter is required but is missing"
return
end
message = @current_credential.server.message(api_params["id"])
message_hash = { id: message.id, token: message.token }
expansions = api_params["_expansions"]
if expansions == true || (expansions.is_a?(Array) && expansions.include?("status"))
message_hash[:status] = {
status: message.status,
last_delivery_attempt: message.last_delivery_attempt&.to_f,
held: message.held,
hold_expiry: message.hold_expiry&.to_f
}
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("details"))
message_hash[:details] = {
rcpt_to: message.rcpt_to,
mail_from: message.mail_from,
subject: message.subject,
message_id: message.message_id,
timestamp: message.timestamp.to_f,
direction: message.scope,
size: message.size,
bounce: message.bounce,
bounce_for_id: message.bounce_for_id,
tag: message.tag,
received_with_ssl: message.received_with_ssl
}
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("inspection"))
message_hash[:inspection] = {
inspected: message.inspected,
spam: message.spam,
spam_score: message.spam_score.to_f,
threat: message.threat,
threat_details: message.threat_details
}
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("plain_body"))
message_hash[:plain_body] = message.plain_body
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("html_body"))
message_hash[:html_body] = message.html_body
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("attachments"))
message_hash[:attachments] = message.attachments.map do |attachment|
{
filename: attachment.filename.to_s,
content_type: attachment.mime_type,
data: Base64.encode64(attachment.body.to_s),
size: attachment.body.to_s.bytesize,
hash: Digest::SHA1.hexdigest(attachment.body.to_s)
}
end
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("headers"))
message_hash[:headers] = message.headers
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("raw_message"))
message_hash[:raw_message] = Base64.encode64(message.raw_message)
end
if expansions == true || (expansions.is_a?(Array) && expansions.include?("activity_entries"))
message_hash[:activity_entries] = {
loads: message.loads,
clicks: message.clicks
}
end
render_success message_hash
rescue Postal::MessageDB::Message::NotFound
render_error "MessageNotFound",
message: "No message found matching provided ID",
id: api_params["id"]
end
# Returns all the deliveries for a given message
#
# URL: /api/v1/messages/deliveries
#
# Parameters: id => REQ: The ID of the message
#
# Response: A array of hashes containing delivery information
# OR an error if the message does not exist.
#
def deliveries
if api_params["id"].blank?
render_parameter_error "`id` parameter is required but is missing"
return
end
message = @current_credential.server.message(api_params["id"])
deliveries = message.deliveries.map do |d|
{
id: d.id,
status: d.status,
details: d.details,
output: d.output&.strip,
sent_with_ssl: d.sent_with_ssl,
log_id: d.log_id,
time: d.time&.to_f,
timestamp: d.timestamp.to_f
}
end
render_success deliveries
rescue Postal::MessageDB::Message::NotFound
render_error "MessageNotFound",
message: "No message found matching provided ID",
id: api_params["id"]
end
end
end
================================================
FILE: app/controllers/legacy_api/send_controller.rb
================================================
# frozen_string_literal: true
module LegacyAPI
class SendController < BaseController
ERROR_MESSAGES = {
"NoRecipients" => "There are no recipients defined to receive this message",
"NoContent" => "There is no content defined for this e-mail",
"TooManyToAddresses" => "The maximum number of To addresses has been reached (maximum 50)",
"TooManyCCAddresses" => "The maximum number of CC addresses has been reached (maximum 50)",
"TooManyBCCAddresses" => "The maximum number of BCC addresses has been reached (maximum 50)",
"FromAddressMissing" => "The From address is missing and is required",
"UnauthenticatedFromAddress" => "The From address is not authorised to send mail from this server",
"AttachmentMissingName" => "An attachment is missing a name",
"AttachmentMissingData" => "An attachment is missing data"
}.freeze
# Send a message with the given options
#
# URL: /api/v1/send/message
#
# Parameters: to => REQ: An array of emails addresses
# cc => An array of email addresses to CC
# bcc => An array of email addresses to BCC
# from => The name/email to send the email from
# sender => The name/email of the 'Sender'
# reply_to => The name/email of the 'Reply-to'
# plain_body => The plain body
# html_body => The HTML body
# bounce => Is this message a bounce?
# tag => A custom tag to add to the message
# custom_headers => A hash of custom headers
# attachments => An array of attachments
# (name, content_type and data (base64))
#
# Response: A array of hashes containing message information
# OR an error if there is an issue sending the message
#
def message
attributes = {}
attributes[:to] = api_params["to"]
attributes[:cc] = api_params["cc"]
attributes[:bcc] = api_params["bcc"]
attributes[:from] = api_params["from"]
attributes[:sender] = api_params["sender"]
attributes[:subject] = api_params["subject"]
attributes[:reply_to] = api_params["reply_to"]
attributes[:plain_body] = api_params["plain_body"]
attributes[:html_body] = api_params["html_body"]
attributes[:bounce] = api_params["bounce"] ? true : false
attributes[:tag] = api_params["tag"]
attributes[:custom_headers] = api_params["headers"] if api_params["headers"]
attributes[:attachments] = []
(api_params["attachments"] || []).each do |attachment|
next unless attachment.is_a?(Hash)
attributes[:attachments] << { name: attachment["name"], content_type: attachment["content_type"], data: attachment["data"], base64: true }
end
message = OutgoingMessagePrototype.new(@current_credential.server, request.ip, "api", attributes)
message.credential = @current_credential
if message.valid?
result = message.create_messages
render_success message_id: message.message_id, messages: result
else
render_error message.errors.first, message: ERROR_MESSAGES[message.errors.first]
end
end
# Send a message by providing a raw message
#
# URL: /api/v1/send/raw
#
# Parameters: rcpt_to => REQ: An array of email addresses to send
# the message to
# mail_from => REQ: the address to send the email from
# data => REQ: base64-encoded mail data
#
# Response: A array of hashes containing message information
# OR an error if there is an issue sending the message
#
def raw
unless api_params["rcpt_to"].is_a?(Array)
render_parameter_error "`rcpt_to` parameter is required but is missing"
return
end
if api_params["mail_from"].blank?
render_parameter_error "`mail_from` parameter is required but is missing"
return
end
if api_params["data"].blank?
render_parameter_error "`data` parameter is required but is missing"
return
end
# Decode the raw message
raw_message = Base64.decode64(api_params["data"])
# Parse through mail to get the from/sender headers
mail = Mail.new(raw_message.split("\r\n\r\n", 2).first)
from_headers = { "from" => mail.from, "sender" => mail.sender }
authenticated_domain = @current_credential.server.find_authenticated_domain_from_headers(from_headers)
# If we're not authenticated, don't continue
if authenticated_domain.nil?
render_error "UnauthenticatedFromAddress"
return
end
# Store the result ready to return
result = { message_id: nil, messages: {} }
if api_params["rcpt_to"].is_a?(Array)
api_params["rcpt_to"].uniq.each do |rcpt_to|
message = @current_credential.server.message_db.new_message
message.rcpt_to = rcpt_to
message.mail_from = api_params["mail_from"]
message.raw_message = raw_message
message.received_with_ssl = true
message.scope = "outgoing"
message.domain_id = authenticated_domain.id
message.credential_id = @current_credential.id
message.bounce = api_params["bounce"] ? true : false
message.save
result[:message_id] = message.message_id if result[:message_id].nil?
result[:messages][rcpt_to] = { id: message.id, token: message.token }
end
end
render_success result
end
end
end
================================================
FILE: app/controllers/messages_controller.rb
================================================
# frozen_string_literal: true
class MessagesController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @message = @server.message_db.message(params[:id].to_i) }
def new
if params[:direction] == "incoming"
@message = IncomingMessagePrototype.new(@server, request.ip, "web-ui", {})
@message.from = session[:test_in_from] || current_user.email_tag
@message.to = @server.routes.order(:name).first&.description
else
@message = OutgoingMessagePrototype.new(@server, request.ip, "web-ui", {})
@message.to = session[:test_out_to] || current_user.email_address
if domain = @server.domains.verified.order(:name).first
@message.from = "test@#{domain.name}"
end
end
@message.subject = "Test Message at #{Time.zone.now.to_fs(:long)}"
@message.plain_body = "This is a message to test the delivery of messages through Postal."
end
def create
if params[:direction] == "incoming"
session[:test_in_from] = params[:message][:from] if params[:message]
@message = IncomingMessagePrototype.new(@server, request.ip, "web-ui", params[:message])
@message.attachments = [{ name: "test.txt", content_type: "text/plain", data: "Hello world!" }]
else
session[:test_out_to] = params[:message][:to] if params[:message]
@message = OutgoingMessagePrototype.new(@server, request.ip, "web-ui", params[:message])
end
if result = @message.create_messages
if result.size == 1
redirect_to_with_json organization_server_message_path(organization, @server, result.first.last[:id]), notice: "Message was queued successfully"
else
redirect_to_with_json [:queue, organization, @server], notice: "Messages queued successfully "
end
else
respond_to do |wants|
wants.html do
flash.now[:alert] = "Your message could not be sent. Ensure that all fields are completed fully. #{result.errors.inspect}"
render "new"
end
wants.json do
render json: { flash: { alert: "Your message could not be sent. Please check all field are completed fully." } }
end
end
end
end
def outgoing
@searchable = true
get_messages("outgoing")
respond_to do |wants|
wants.html
wants.json do
render json: {
flash: flash.each_with_object({}) { |(type, message), hash| hash[type] = message },
region_html: render_to_string(partial: "index", formats: [:html])
}
end
end
end
def incoming
@searchable = true
get_messages("incoming")
respond_to do |wants|
wants.html
wants.json do
render json: {
flash: flash.each_with_object({}) { |(type, message), hash| hash[type] = message },
region_html: render_to_string(partial: "index", formats: [:html])
}
end
end
end
def held
get_messages("held")
end
def deliveries
render json: { html: render_to_string(partial: "deliveries", locals: { message: @message }) }
end
def html_raw
render html: @message.html_body_without_tracking_image.html_safe
end
def spam_checks
@spam_checks = @message.spam_checks.sort_by { |s| s["score"] }.reverse
end
def attachment
if @message.attachments.size > params[:attachment].to_i
attachment = @message.attachments[params[:attachment].to_i]
send_data attachment.body, content_type: attachment.mime_type, disposition: "download", filename: attachment.filename
else
redirect_to attachments_organization_server_message_path(organization, @server, @message.id), alert: "Attachment not found. Choose an attachment from the list below."
end
end
def download
if @message.raw_message
send_data @message.raw_message, filename: "Message-#{organization.permalink}-#{@server.permalink}-#{@message.id}.eml", content_type: "text/plain"
else
redirect_to organization_server_message_path(organization, @server, @message.id), alert: "We no longer have the raw message stored for this message."
end
end
def retry
if @message.raw_message?
if @message.queued_message
@message.queued_message.retry_now
flash[:notice] = "This message will be retried shortly."
elsif @message.held?
@message.add_to_message_queue(manual: true)
flash[:notice] = "This message has been released. Delivery will be attempted shortly."
else
@message.add_to_message_queue(manual: true)
flash[:notice] = "This message will be redelivered shortly."
end
else
flash[:alert] = "This message is no longer available."
end
redirect_to_with_json organization_server_message_path(organization, @server, @message.id)
end
def cancel_hold
@message.cancel_hold
redirect_to_with_json organization_server_message_path(organization, @server, @message.id)
end
def remove_from_queue
if @message.queued_message && !@message.queued_message.locked?
@message.queued_message.destroy
end
redirect_to_with_json organization_server_message_path(organization, @server, @message.id)
end
def suppressions
@suppressions = @server.message_db.suppression_list.all_with_pagination(params[:page])
end
def activity
@entries = @message.activity_entries
end
private
def get_messages(scope)
if scope == "held"
options = { where: { held: true } }
else
options = { where: { scope: scope, spam: false }, order: :timestamp, direction: "desc" }
if @query = (params[:query] || session["msg_query_#{@server.id}_#{scope}"]).presence
session["msg_query_#{@server.id}_#{scope}"] = @query
qs = QueryString.new(@query)
if qs.empty?
flash.now[:alert] = "It doesn't appear you entered anything to filter on. Please double check your query."
else
@queried = true
if qs[:order] == "oldest-first"
options[:direction] = "asc"
end
options[:where][:rcpt_to] = qs[:to] if qs[:to]
options[:where][:mail_from] = qs[:from] if qs[:from]
options[:where][:status] = qs[:status] if qs[:status]
options[:where][:token] = qs[:token] if qs[:token]
if qs[:msgid]
options[:where][:message_id] = qs[:msgid]
options[:where].delete(:spam)
options[:where].delete(:scope)
end
options[:where][:tag] = qs[:tag] if qs[:tag]
options[:where][:id] = qs[:id] if qs[:id]
options[:where][:spam] = true if qs[:spam] == "yes" || qs[:spam] == "y"
if qs[:before] || qs[:after]
options[:where][:timestamp] = {}
if qs[:before]
begin
options[:where][:timestamp][:less_than] = get_time_from_string(qs[:before]).to_f
rescue TimeUndetermined
flash.now[:alert] = "Couldn't determine time for before from '#{qs[:before]}'"
end
end
if qs[:after]
begin
options[:where][:timestamp][:greater_than] = get_time_from_string(qs[:after]).to_f
rescue TimeUndetermined
flash.now[:alert] = "Couldn't determine time for after from '#{qs[:after]}'"
end
end
end
end
else
session["msg_query_#{@server.id}_#{scope}"] = nil
end
end
@messages = @server.message_db.messages_with_pagination(params[:page], options)
end
class TimeUndetermined < Postal::Error; end
def get_time_from_string(string)
begin
if string =~ /\A(\d{2,4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})\z/
time = Time.new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i, ::Regexp.last_match(4).to_i, ::Regexp.last_match(5).to_i)
elsif string =~ /\A(\d{2,4})-(\d{2})-(\d{2})\z/
time = Time.new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i, 0)
else
time = Chronic.parse(string, context: :past)
end
rescue StandardError
time = nil
end
raise TimeUndetermined, "Couldn't determine a suitable time from '#{string}'" if time.nil?
time
end
end
================================================
FILE: app/controllers/organization_ip_pools_controller.rb
================================================
# frozen_string_literal: true
class OrganizationIPPoolsController < ApplicationController
include WithinOrganization
before_action :admin_required, only: [:assignments]
def index
@ip_pools = organization.ip_pools.order(:name)
end
def assignments
organization.ip_pool_ids = params[:ip_pools]
organization.save!
redirect_to [organization, :ip_pools], notice: "Organization IP pools have been updated successfully"
end
end
================================================
FILE: app/controllers/organizations_controller.rb
================================================
# frozen_string_literal: true
class OrganizationsController < ApplicationController
before_action :admin_required, only: [:new, :create, :delete, :destroy]
def index
if current_user.admin?
@organizations = Organization.present.order(:name).to_a
else
@organizations = current_user.organizations.present.order(:name).to_a
if @organizations.size == 1 && params[:nrd].nil?
redirect_to organization_root_path(@organizations.first)
end
end
end
def new
@organization = Organization.new
end
def edit
@organization_obj = current_user.organizations_scope.find(organization.id)
end
def create
@organization = Organization.new(params.require(:organization).permit(:name, :permalink))
@organization.owner = current_user
if @organization.save
redirect_to_with_json organization_root_path(@organization)
else
render_form_errors "new", @organization
end
end
def update
@organization_obj = current_user.organizations_scope.find(organization.id)
if @organization_obj.update(params.require(:organization).permit(:name, :time_zone))
redirect_to_with_json organization_settings_path(@organization_obj), notice: "Settings for #{@organization_obj.name} have been saved successfully."
else
render_form_errors "edit", @organization_obj
end
end
def destroy
if params[:confirm_text].blank? || params[:confirm_text].downcase.strip != organization.name.downcase.strip
respond_to do |wants|
alert_text = "The text you entered does not match the organization name. Please check and try again."
wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }
wants.json { render json: { alert: alert_text } }
end
return
end
organization.soft_destroy
redirect_to_with_json root_path(nrd: 1), notice: "#{@organization.name} has been removed successfully."
end
private
def organization
return unless [:edit, :update, :delete, :destroy].include?(action_name.to_sym)
@organization ||= params[:org_permalink] ? current_user.organizations_scope.find_by_permalink!(params[:org_permalink]) : nil
end
helper_method :organization
end
================================================
FILE: app/controllers/routes_controller.rb
================================================
# frozen_string_literal: true
class RoutesController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @route = @server.routes.find_by_uuid!(params[:id]) }
def index
@routes = @server.routes.order(:name).includes(:domain, :endpoint).to_a
end
def new
@route = @server.routes.build
end
def create
@route = @server.routes.build(safe_params)
if @route.save
redirect_to_with_json [organization, @server, :routes]
else
render_form_errors "new", @route
end
end
def update
if @route.update(safe_params)
redirect_to_with_json [organization, @server, :routes]
else
render_form_errors "edit", @route
end
end
def destroy
@route.destroy
redirect_to_with_json [organization, @server, :routes]
end
private
def safe_params
params.require(:route).permit(:name, :domain_id, :spam_mode, :_endpoint, additional_route_endpoints_array: [])
end
end
================================================
FILE: app/controllers/servers_controller.rb
================================================
# frozen_string_literal: true
class ServersController < ApplicationController
include WithinOrganization
before_action :admin_required, only: [:advanced, :suspend, :unsuspend]
before_action { params[:id] && @server = organization.servers.present.find_by_permalink!(params[:id]) }
def index
@servers = organization.servers.present.order(:name).to_a
end
def show
if @server.created_at < 48.hours.ago
@graph_type = :daily
graph_data = @server.message_db.statistics.get(:daily, [:incoming, :outgoing, :bounces], Time.now, 30)
elsif @server.created_at < 24.hours.ago
@graph_type = :hourly
graph_data = @server.message_db.statistics.get(:hourly, [:incoming, :outgoing, :bounces], Time.now, 48)
else
@graph_type = :hourly
graph_data = @server.message_db.statistics.get(:hourly, [:incoming, :outgoing, :bounces], Time.now, 24)
end
@first_date = graph_data.first.first
@last_date = graph_data.last.first
@graph_data = graph_data.map(&:last)
@messages = @server.message_db.messages(order: "id", direction: "desc", limit: 6)
end
def new
@server = organization.servers.build
end
def create
@server = organization.servers.build(safe_params(:permalink))
if @server.save
redirect_to_with_json organization_server_path(organization, @server)
else
render_form_errors "new", @server
end
end
def update
extra_params = [:spam_threshold, :spam_failure_threshold, :postmaster_address]
if current_user.admin?
extra_params += [
:send_limit,
:allow_sender,
:privacy_mode,
:log_smtp_data,
:outbound_spam_threshold,
:message_retention_days,
:raw_message_retention_days,
:raw_message_retention_size,
]
end
if @server.update(safe_params(*extra_params))
redirect_to_with_json organization_server_path(organization, @server), notice: "Server settings have been updated"
else
render_form_errors "edit", @server
end
end
def destroy
if params[:confirm_text].blank? || params[:confirm_text].downcase.strip != @server.name.downcase.strip
respond_to do |wants|
alert_text = "The text you entered does not match the server name. Please check and try again."
wants.html { redirect_to organization_delete_path(@organization), alert: alert_text }
wants.json { render json: { alert: alert_text } }
end
return
end
@server.soft_destroy
redirect_to_with_json organization_root_path(organization), notice: "#{@server.name} has been deleted successfully"
end
def queue
@messages = @server.queued_messages.order(id: :desc).page(params[:page]).includes(:ip_address)
@messages_with_message = @messages.include_message
end
def suspend
@server.suspend(params[:reason])
redirect_to_with_json [organization, @server], notice: "Server has been suspended"
end
def unsuspend
@server.unsuspend
redirect_to_with_json [organization, @server], notice: "Server has been unsuspended"
end
private
def safe_params(*extras)
params.require(:server).permit(:name, :mode, :ip_pool_id, *extras)
end
end
================================================
FILE: app/controllers/sessions_controller.rb
================================================
# frozen_string_literal: true
class SessionsController < ApplicationController
layout "sub"
before_action :require_local_authentication, only: [:create, :begin_password_reset, :finish_password_reset]
skip_before_action :login_required, only: [:new, :create, :begin_password_reset, :finish_password_reset, :ip, :raise_error, :create_from_oidc, :oauth_failure]
def create
login(User.authenticate(params[:email_address], params[:password]))
flash[:remember_login] = true
redirect_to_with_return_to root_path
rescue Postal::Errors::AuthenticationError
flash.now[:alert] = "The credentials you've provided are incorrect. Please check and try again."
render "new"
end
def destroy
auth_session.invalidate! if logged_in?
reset_session
redirect_to login_path
end
def persist
auth_session.persist! if logged_in?
render plain: "OK"
end
def begin_password_reset
return unless request.post?
user_scope = Postal::Config.oidc.enabled? ? User.with_password : User
user = user_scope.find_by(email_address: params[:email_address])
if user.nil?
redirect_to login_reset_path(return_to: params[:return_to]), alert: "No local user exists with that e-mail address. Please check and try again."
return
end
user.begin_password_reset(params[:return_to])
redirect_to login_path(return_to: params[:return_to]), notice: "Please check your e-mail and click the link in the e-mail we've sent you."
end
def finish_password_reset
@user = User.where(password_reset_token: params[:token]).where("password_reset_token_valid_until > ?", Time.now).first
if @user.nil?
redirect_to login_path(return_to: params[:return_to]), alert: "This link has expired or never existed. Please choose reset password to try again."
end
return unless request.post?
if params[:password].blank?
flash.now[:alert] = "You must enter a new password"
return
end
@user.password = params[:password]
@user.password_confirmation = params[:password_confirmation]
return unless @user.save
login(@user)
redirect_to_with_return_to root_path, notice: "Your new password has been set and you've been logged in."
end
def ip
render plain: "ip: #{request.ip} remote ip: #{request.remote_ip}"
end
def create_from_oidc
unless Postal::Config.oidc.enabled?
raise Postal::Error, "OIDC cannot be used unless enabled in the configuration"
end
auth = request.env["omniauth.auth"]
user = User.find_from_oidc(auth.extra.raw_info, logger: Postal.logger)
if user.nil?
redirect_to login_path, alert: "No user was found matching your identity. Please contact your administrator."
return
end
login(user)
flash[:remember_login] = true
redirect_to_with_return_to root_path
end
def oauth_failure
redirect_to login_path, alert: "An issue occurred while logging you in with OpenID. Please try again later or contact your administrator."
end
private
def require_local_authentication
return if Postal::Config.oidc.local_authentication_enabled?
redirect_to login_path, alert: "Local authentication is not enabled"
end
end
================================================
FILE: app/controllers/smtp_endpoints_controller.rb
================================================
# frozen_string_literal: true
class SMTPEndpointsController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @smtp_endpoint = @server.smtp_endpoints.find_by_uuid!(params[:id]) }
def index
@smtp_endpoints = @server.smtp_endpoints.order(:name).to_a
end
def new
@smtp_endpoint = @server.smtp_endpoints.build
end
def create
@smtp_endpoint = @server.smtp_endpoints.build(safe_params)
if @smtp_endpoint.save
flash[:notice] = params[:return_notice] if params[:return_notice].present?
redirect_to_with_json [:return_to, [organization, @server, :smtp_endpoints]]
else
render_form_errors "new", @smtp_endpoint
end
end
def update
if @smtp_endpoint.update(safe_params)
redirect_to_with_json [organization, @server, :smtp_endpoints]
else
render_form_errors "edit", @smtp_endpoint
end
end
def destroy
@smtp_endpoint.destroy
redirect_to_with_json [organization, @server, :smtp_endpoints]
end
private
def safe_params
params.require(:smtp_endpoint).permit(:name, :hostname, :port, :ssl_mode)
end
end
================================================
FILE: app/controllers/track_domains_controller.rb
================================================
# frozen_string_literal: true
class TrackDomainsController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @track_domain = @server.track_domains.find_by_uuid!(params[:id]) }
def index
@track_domains = @server.track_domains.order(:name).to_a
end
def new
@track_domain = @server.track_domains.build
end
def create
@track_domain = @server.track_domains.build(params.require(:track_domain).permit(:name, :domain_id, :track_loads, :track_clicks, :excluded_click_domains, :ssl_enabled))
if @track_domain.save
redirect_to_with_json [:return_to, [organization, @server, :track_domains]]
else
render_form_errors "new", @track_domain
end
end
def update
if @track_domain.update(params.require(:track_domain).permit(:track_loads, :track_clicks, :excluded_click_domains, :ssl_enabled))
redirect_to_with_json [organization, @server, :track_domains]
else
render_form_errors "edit", @track_domain
end
end
def destroy
@track_domain.destroy
redirect_to_with_json [organization, @server, :track_domains]
end
def check
if @track_domain.check_dns
redirect_to_with_json [organization, @server, :track_domains], notice: "Your CNAME for #{@track_domain.full_name} looks good!"
else
redirect_to_with_json [organization, @server, :track_domains], alert: "There seems to be something wrong with your DNS record. Check documentation for information."
end
end
def toggle_ssl
@track_domain.update(ssl_enabled: !@track_domain.ssl_enabled)
redirect_to_with_json [organization, @server, :track_domains], notice: "SSL settings for #{@track_domain.full_name} updated successfully."
end
end
================================================
FILE: app/controllers/user_controller.rb
================================================
# frozen_string_literal: true
class UserController < ApplicationController
skip_before_action :login_required, only: [:new, :create, :join]
def new
@user_invite = UserInvite.active.find_by!(uuid: params[:invite_token])
@user = User.new
@user.email_address = @user_invite.email_address
render layout: "sub"
end
def edit
@user = User.find(current_user.id)
end
def create
@user_invite = UserInvite.active.find_by!(uuid: params[:invite_token])
@user = User.new(params.require(:user).permit(:first_name, :last_name, :email_address, :password, :password_confirmation))
@user.email_verified_at = Time.now
if @user.save
@user_invite.accept(@user)
self.current_user = @user
redirect_to root_path
else
render "new", layout: "sub"
end
end
def update
@user = User.find(current_user.id)
safe_params = [:first_name, :last_name, :time_zone, :email_address]
if @user.password? && Postal::Config.oidc.local_authentication_enabled?
safe_params += [:password, :password_confirmation]
if @user.authenticate_with_previous_password_first(params[:password])
@password_correct = true
else
respond_to do |wants|
wants.html do
flash.now[:alert] = "The current password you have entered is incorrect. Please check and try again."
render "edit"
end
wants.json do
render json: { alert: "The current password you've entered is incorrect. Please check and try again" }
end
end
return
end
end
@user.attributes = params.require(:user).permit(safe_params)
if @user.save
redirect_to_with_json settings_path, notice: "Your settings have been updated successfully."
else
render_form_errors "edit", @user
end
end
end
================================================
FILE: app/controllers/users_controller.rb
================================================
# frozen_string_literal: true
class UsersController < ApplicationController
before_action :admin_required
before_action { params[:id] && @user = User.find_by!(uuid: params[:id]) }
def index
@users = User.order(:first_name, :last_name).includes(:organization_users)
end
def new
@user = User.new(admin: true)
end
def edit
end
def create
@user = User.new(params.require(:user).permit(:email_address, :first_name, :last_name, :password, :password_confirmation, :admin, organization_ids: []))
if @user.save
redirect_to_with_json :users, notice: "#{@user.name} has been created successfully."
else
render_form_errors "new", @user
end
end
def update
@user.attributes = params.require(:user).permit(:email_address, :first_name, :last_name, :admin, organization_ids: [])
if @user == current_user && !@user.admin?
respond_to do |wants|
wants.html { redirect_to users_path, alert: "You cannot change your own admin status" }
wants.json { render json: { form_errors: ["You cannot change your own admin status"] }, status: :unprocessable_entity }
end
return
end
if @user.save
redirect_to_with_json :users, notice: "Permissions for #{@user.name} have been updated successfully."
else
render_form_errors "edit", @user
end
end
def destroy
if @user == current_user
redirect_to_with_json :users, alert: "You cannot delete your own user."
return
end
@user.destroy!
redirect_to_with_json :users, notice: "#{@user.name} has been removed"
end
end
================================================
FILE: app/controllers/webhooks_controller.rb
================================================
# frozen_string_literal: true
class WebhooksController < ApplicationController
include WithinOrganization
before_action { @server = organization.servers.present.find_by_permalink!(params[:server_id]) }
before_action { params[:id] && @webhook = @server.webhooks.find_by_uuid!(params[:id]) }
def index
@webhooks = @server.webhooks.order(:url).to_a
end
def new
@webhook = @server.webhooks.build(all_events: true)
end
def create
@webhook = @server.webhooks.build(safe_params)
if @webhook.save
redirect_to_with_json [organization, @server, :webhooks]
else
render_form_errors "new", @webhook
end
end
def update
if @webhook.update(safe_params)
redirect_to_with_json [organization, @server, :webhooks]
else
render_form_errors "edit", @webhook
end
end
def destroy
@webhook.destroy
redirect_to_with_json [organization, @server, :webhooks]
end
def history
@current_page = params[:page] ? params[:page].to_i : 1
@requests = @server.message_db.webhooks.list(@current_page)
end
def history_request
@req = @server.message_db.webhooks.find(params[:uuid])
end
private
def safe_params
params.require(:webhook).permit(:name, :url, :all_events, :enabled, events: [])
end
end
================================================
FILE: app/controllers/well_known_controller.rb
================================================
# frozen_string_literal: true
class WellKnownController < ApplicationController
layout false
skip_before_action :set_browser_id
skip_before_action :login_required
skip_before_action :set_timezone
def jwks
render json: JWT::JWK::Set.new(Postal.signer.jwk).export.to_json
end
end
================================================
FILE: app/helpers/application_helper.rb
================================================
# frozen_string_literal: true
module ApplicationHelper
def format_delivery_details(server, text)
text = h(text)
text.gsub!(//) do
id = ::Regexp.last_match(1).to_i
link_to("message ##{id}", organization_server_message_path(server.organization, server, id), class: "u-link")
end
text.html_safe
end
def style_width(width, options = {})
width = 100 if width > 100.0
width = 0 if width < 0.0
style = "width:#{width}%;"
if options[:color]
if width >= 100
style += " background-color:#e2383a;"
elsif width >= 90
style += " background-color:#e8581f;"
end
end
style
end
def domain_options_for_select(server, selected_domain = nil, options = {})
String.new.tap do |s|
s << " "
server_domains = server.domains.verified.order(:name)
unless server_domains.empty?
s << ""
server_domains.each do |domain|
selected = domain == selected_domain ? "selected='selected'" : ""
s << "#{domain.name} "
end
s << " "
end
organization_domains = server.organization.domains.verified.order(:name)
unless organization_domains.empty?
s << ""
organization_domains.each do |domain|
selected = domain == selected_domain ? "selected='selected'" : ""
s << "#{domain.name} "
end
s << " "
end
end.html_safe
end
def endpoint_options_for_select(server, selected_value = nil, options = {})
String.new.tap do |s|
s << " "
http_endpoints = server.http_endpoints.order(:name).to_a
if http_endpoints.present?
s << ""
http_endpoints.each do |endpoint|
value = "#{endpoint.class}##{endpoint.uuid}"
selected = value == selected_value ? "selected='selected'" : ""
s << "#{endpoint.description} "
end
s << " "
end
smtp_endpoints = server.smtp_endpoints.order(:name).to_a
if smtp_endpoints.present?
s << ""
smtp_endpoints.each do |endpoint|
value = "#{endpoint.class}##{endpoint.uuid}"
selected = value == selected_value ? "selected='selected'" : ""
s << "#{endpoint.description} "
end
s << " "
end
address_endpoints = server.address_endpoints.order(:address).to_a
if address_endpoints.present?
s << ""
address_endpoints.each do |endpoint|
value = "#{endpoint.class}##{endpoint.uuid}"
selected = value == selected_value ? "selected='selected'" : ""
s << "#{endpoint.address} "
end
s << " "
end
unless options[:other] == false
s << ""
Route::MODES.each do |mode|
next if mode == "Endpoint"
selected = (selected_value == mode ? "selected='selected'" : "")
text = t("route_modes.#{mode.underscore}")
s << "#{text} "
end
s << " "
end
end.html_safe
end
def postal_version_string
string = Postal.version
string += " (#{Postal.branch})" if Postal.branch &&
Postal.branch != "main"
string
end
end
================================================
FILE: app/lib/dkim_header.rb
================================================
# frozen_string_literal: true
class DKIMHeader
def initialize(domain, message)
if domain && domain.dkim_status == "OK"
@domain_name = domain.name
@dkim_key = domain.dkim_key
@dkim_identifier = domain.dkim_identifier
else
@domain_name = Postal::Config.dns.return_path_domain
@dkim_key = Postal.signer.private_key
@dkim_identifier = Postal::Config.dns.dkim_identifier
end
@domain = domain
@message = message
@raw_headers, @raw_body = @message.gsub(/\r?\n/, "\r\n").split(/\r\n\r\n/, 2)
end
def dkim_header
"DKIM-Signature: v=1; " + dkim_properties.join("\r\n\t") + signature.scan(/.{1,72}/).join("\r\n\t")
end
private
def headers
@headers ||= @raw_headers.to_s.gsub(/\r?\n\s/, " ").split(/\r?\n/)
end
def header_names
normalized_headers.map { |h| h.split(":")[0].strip }
end
def normalized_headers
[].tap do |new_headers|
dkim_headers = headers.select do |h|
h.match(/
^(
from|sender|reply-to|subject|date|message-id|to|cc|mime-version|content-type|content-transfer-encoding|
resent-to|resent-cc|resent-from|resent-sender|resent-message-id|in-reply-to|references|list-id|list-help|
list-owner|list-unsubscribe|list-unsubscribe-post|list-subscribe|list-post
):/ix)
end
dkim_headers.each do |h|
new_headers << normalize_header(h)
end
end
end
def normalize_header(content)
content = content.dup
# From the DKIM RFC6376
# https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2
# Split the key and value.
key, value = content.split(":", 2)
# Convert all header field names (not the header field values) to
# lowercase. For example, convert "SUBJect: AbC" to "subject: AbC".
key.downcase!
# Unfold all header field continuation lines as described in [RFC5322]
value.gsub!(/\r?\n[ \t]+/, " ")
# Convert all sequences of one or more WSP characters to a single SP character.
value.gsub!(/[ \t]+/, " ")
# Delete all WSP characters at the end of each unfolded header field value.
value.gsub!(/[ \t]*\z/, "")
# Delete any WSP characters remaining after the colon separating the header field name from the header field value.
value.gsub!(/\A[ \t]*/, "")
# Join together
key + ":" + value
end
def normalized_body
@normalized_body ||= begin
content = @raw_body.dup
# From the DKIM RFC6376
# https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.4
# a. Reduce whitespace
#
# * Reduce all sequences of WSP within a line to a single SP character.
content.gsub!(/[ \t]+/, " ")
# * Ignore all whitespace at the end of lines. Implementations MUST NOT
# remove the CRLF at the end of the line.
content.gsub!(/ \r\n/, "\r\n")
# b. Ignore all empty lines at the end of the message body.
content.gsub!(/[ \r\n]*\z/, "")
content += "\r\n"
content
end
end
def body_hash
@body_hash ||= Base64.encode64(Digest::SHA256.digest(normalized_body)).strip
end
def dkim_properties
@dkim_properties ||= [].tap do |header|
header << "a=rsa-sha256; c=relaxed/relaxed;"
header << "d=#{@domain_name};"
header << "s=#{@dkim_identifier}; t=#{Time.now.utc.to_i};"
header << "bh=#{body_hash};"
header << "h=#{header_names.join(':')};"
header << "b="
end
end
def dkim_header_for_signing
"dkim-signature:v=1; #{dkim_properties.join(' ')}"
end
def signable_header_string
(normalized_headers + [dkim_header_for_signing]).join("\r\n")
end
def signature
Base64.encode64(@dkim_key.sign(OpenSSL::Digest.new("SHA256"), signable_header_string)).gsub("\n", "")
end
end
================================================
FILE: app/lib/dns_resolver.rb
================================================
# frozen_string_literal: true
require "resolv"
class DNSResolver
class LocalResolversUnavailableError < StandardError
end
attr_reader :nameservers
attr_reader :timeout
def initialize(nameservers)
@nameservers = nameservers
end
# Return all A records for the given name
#
# @param [String] name
# @return [Array]
def a(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::A, **options).map do |s|
s.address.to_s
end
end
# Return all AAAA records for the given name
#
# @param [String] name
# @return [Array]
def aaaa(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::AAAA, **options).map do |s|
s.address.to_s
end
end
# Return all TXT records for the given name
#
# @param [String] name
# @return [Array]
def txt(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::TXT, **options).map do |s|
s.data.to_s.strip
end
end
# Return all CNAME records for the given name
#
# @param [String] name
# @return [Array]
def cname(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::CNAME, **options).map do |s|
s.name.to_s.downcase
end
end
# Return all MX records for the given name
#
# @param [String] name
# @return [Array>]
def mx(name, **options)
records = get_resources(name, Resolv::DNS::Resource::IN::MX, **options).map do |m|
[m.preference.to_i, m.exchange.to_s]
end
records.sort do |a, b|
if a[0] == b[0]
[-1, 1].sample
else
a[0] <=> b[0]
end
end
end
# Return the effective nameserver names for a given domain name.
#
# @param [String] name
# @return [Array]
def effective_ns(name, **options)
records = []
parts = name.split(".")
(parts.size - 1).times do |n|
d = parts[n, parts.size - n + 1].join(".")
records = get_resources(d, Resolv::DNS::Resource::IN::NS, **options).map do |s|
s.name.to_s
end
break if records.present?
end
records
end
# Return the hostname for a given IP address.
# Returns the IP address itself if no hostname can be determined.
#
# @param [String] ip_address
# @return [String]
def ip_to_hostname(ip_address, **options)
dns(**options) do |dns|
dns.getname(ip_address)&.to_s
end
rescue Resolv::ResolvError => e
raise if e.message =~ /timeout/ && options[:raise_timeout_errors]
ip_address
end
private
def dns(raise_timeout_errors: false)
Resolv::DNS.open(nameserver: @nameservers,
raise_timeout_errors: raise_timeout_errors) do |dns|
dns.timeouts = [
Postal::Config.dns.timeout,
Postal::Config.dns.timeout / 2,
Postal::Config.dns.timeout / 2,
]
yield dns
end
end
def get_resources(name, type, **options)
encoded_name = DomainName::Punycode.encode_hostname(name)
dns(**options) do |dns|
dns.getresources(encoded_name, type)
end
end
class << self
# Return a resolver which will use the nameservers for the given domain
#
# @param [String] name
# @return [DNSResolver]
def for_domain(name)
nameservers = local.effective_ns(name)
ips = nameservers.map do |ns|
local.a(ns)
end.flatten.uniq
new(ips)
end
# Return a local resolver to use for lookups
#
# @return [DNSResolver]
def local
@local ||= begin
resolv_conf_path = Postal::Config.dns.resolv_conf_path
raise LocalResolversUnavailableError, "No resolver config found at #{resolv_conf_path}" unless File.file?(resolv_conf_path)
resolv_conf = Resolv::DNS::Config.parse_resolv_conf(resolv_conf_path)
if resolv_conf.nil? || resolv_conf[:nameserver].nil? || resolv_conf[:nameserver].empty?
raise LocalResolversUnavailableError, "Could not find nameservers in #{resolv_conf_path}"
end
new(resolv_conf[:nameserver])
end
end
end
end
================================================
FILE: app/lib/message_dequeuer/base.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class Base
class StopProcessing < StandardError
end
attr_reader :queued_message
attr_reader :logger
attr_reader :state
def initialize(queued_message, logger:, state: nil)
@queued_message = queued_message
@logger = logger
@state = state || State.new
end
def process
raise NotImplemented
end
class << self
def process(message, **kwargs)
new(message, **kwargs).process
end
end
private
def stop_processing
raise StopProcessing
end
def catch_stops
yield if block_given?
true
rescue StopProcessing
false
end
def remove_from_queue
@queued_message.destroy
end
def create_delivery(type, **kwargs)
@queued_message.message.create_delivery(type, **kwargs)
end
def log(text, **tags)
logger.info text, **tags
end
def increment_live_stats
queued_message.message.database.live_stats.increment(queued_message.message.scope)
end
def hold_if_server_development_mode
return if queued_message.manual?
return unless queued_message.server.mode == "Development"
log "server is in development mode, holding"
create_delivery "Held", details: "Server is in development mode."
remove_from_queue
stop_processing
end
def log_sender_result
log_details = @result.details
if @additional_delivery_details
log_details += "." unless log_details =~ /\.\z/
log_details += " "
log_details += @additional_delivery_details
end
create_delivery @result.type, details: log_details,
output: @result.output&.strip,
sent_with_ssl: @result.secure,
log_id: @result.log_id,
time: @result.time
end
def handle_exception(exception)
log "internal error: #{exception.class}: #{exception.message}"
exception.backtrace.each { |line| log(line) }
queued_message.retry_later unless queued_message.destroyed?
log "message requeued for trying later, at #{queued_message.retry_after}"
if defined?(Sentry)
Sentry.capture_exception(exception, extra: {
server_id: queued_message.server_id,
queued_message_id: queued_message.message_id
})
end
queued_message.message&.create_delivery("Error",
details: "An internal error occurred while sending " \
"this message. This message will be retried " \
"automatically.",
output: "#{exception.class}: #{exception.message}")
end
end
end
================================================
FILE: app/lib/message_dequeuer/incoming_message_processor.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class IncomingMessageProcessor < Base
attr_reader :route
def process
log "message is incoming"
catch_stops do
handle_bounces
increment_live_stats
inspect_message
fail_if_spam
hold_if_server_development_mode
find_route
hold_or_reject_spam
accept_mail_without_endpoints
hold_messages
bounce_messages
send_message_to_sender
send_bounce_on_hard_fail
log_sender_result
finish_processing
end
rescue StandardError => e
handle_exception(e)
end
private
def handle_bounces
return unless queued_message.message.bounce
log "message is a bounce"
original_messages = queued_message.message.original_messages
unless original_messages.empty?
queued_message.message.original_messages.each do |orig_msg|
queued_message.message.update(bounce_for_id: orig_msg.id, domain_id: orig_msg.domain_id)
create_delivery "Processed", details: "This has been detected as a bounce message for ."
orig_msg.bounce!(queued_message.message)
log "bounce linked with message #{orig_msg.id}"
end
remove_from_queue
stop_processing
end
# This message was sent to the return path but hasn't been matched
# to an original message. If we have a route for this, route it
# otherwise we'll drop at this point.
return unless queued_message.message.route_id.nil?
log "no source messages found, hard failing"
create_delivery "HardFail", details: "This message was a bounce but we couldn't link it with any outgoing message and there was no route for it."
remove_from_queue
stop_processing
end
def inspect_message
return if queued_message.message.inspected
log "inspecting message"
queued_message.message.inspect_message
return unless queued_message.message.inspected
is_spam = queued_message.message.spam_score > queued_message.server.spam_threshold
if is_spam
queued_message.message.update(spam: true)
log "message is spam (scored #{queued_message.message.spam_score}, threshold is #{queued_message.server.spam_threshold})"
end
queued_message.message.append_headers(
"X-Postal-Spam: #{queued_message.message.spam ? 'yes' : 'no'}",
"X-Postal-Spam-Threshold: #{queued_message.server.spam_threshold}",
"X-Postal-Spam-Score: #{queued_message.message.spam_score}",
"X-Postal-Threat: #{queued_message.message.threat ? 'yes' : 'no'}"
)
log "message inspected, headers added", spam: queued_message.message.spam?, spam_score: queued_message.message.spam_score, threat: queued_message.message.threat?
end
def fail_if_spam
return if queued_message.message.spam_score < queued_message.server.spam_failure_threshold
log "message has a spam score higher than the server's maxmimum, hard failing", server_threshold: queued_message.server.spam_failure_threshold
create_delivery "HardFail",
details: "Message's spam score is higher than the failure threshold for this server. " \
"Threshold is currently #{queued_message.server.spam_failure_threshold}."
remove_from_queue
stop_processing
end
def find_route
@route = queued_message.message.route
return if @route
log "no route and/or endpoint available for processing, hard failing"
create_delivery "HardFail", details: "Message does not have a route and/or endpoint available for delivery."
remove_from_queue
stop_processing
end
def hold_or_reject_spam
return unless queued_message.message.spam
return if queued_message.manual?
case @route.spam_mode
when "Quarantine"
log "message is spam and route says to quarantine spam message, holding"
create_delivery "Held", details: "Message placed into quarantine."
when "Fail"
log "message is spam and route says to fail spam message, hard failing"
create_delivery "HardFail", details: "Message is spam and the route specifies it should be failed."
else
return
end
remove_from_queue
stop_processing
end
def accept_mail_without_endpoints
return unless @route.mode == "Accept"
log "route says to accept without endpoint, marking as processed"
create_delivery "Processed", details: "Message has been accepted but not sent to any endpoints."
remove_from_queue
stop_processing
end
def hold_messages
return unless @route.mode == "Hold"
if queued_message.manual?
log "route says to hold and message was queued manually, marking as processed"
create_delivery "Processed", details: "Message has been processed."
else
log "route says to hold, marking as held"
create_delivery "Held", details: "Message has been accepted but not sent to any endpoints."
end
remove_from_queue
stop_processing
end
def bounce_messages
return unless route.mode == "Bounce" || route.mode == "Reject"
log "route says to bounce, hard failing and sending bounce"
if id = queued_message.send_bounce
log "bounce sent with id #{id}"
create_delivery "HardFail", details: "Message has been bounced because the route asks for this. See message "
end
remove_from_queue
stop_processing
end
def send_message_to_sender
@result = @state.send_result
return if @result
case queued_message.message.endpoint
when SMTPEndpoint
sender = @state.sender_for(SMTPSender, queued_message.message.recipient_domain, nil, servers: [queued_message.message.endpoint.to_smtp_client_server])
when HTTPEndpoint
sender = @state.sender_for(HTTPSender, queued_message.message.endpoint)
when AddressEndpoint
sender = @state.sender_for(SMTPSender, queued_message.message.endpoint.domain, nil, rcpt_to: queued_message.message.endpoint.address)
else
log "invalid endpoint for route (#{queued_message.message.endpoint_type})"
create_delivery "HardFail", details: "Invalid endpoint for route."
remove_from_queue
stop_processing
end
@result = sender.send_message(queued_message.message)
return unless @result.connect_error
@state.send_result = @result
end
def send_bounce_on_hard_fail
return unless @result.type == "HardFail"
if @result.suppress_bounce
log "suppressing bounce message after hard fail"
return
end
return unless queued_message.message.send_bounces?
log "sending a bounce because message hard failed"
return unless bounce_id = queued_message.send_bounce
@additional_delivery_details = "Sent bounce message to sender (see message )"
end
def finish_processing
if @result.retry
queued_message.retry_later(@result.retry.is_a?(Integer) ? @result.retry : nil)
log "message requeued for trying later, at #{queued_message.retry_after}"
queued_message.allocate_ip_address
queued_message.update_column(:ip_address_id, queued_message.ip_address&.id)
stop_processing
end
log "message processing completed"
queued_message.message.endpoint.mark_as_used
remove_from_queue
end
end
end
================================================
FILE: app/lib/message_dequeuer/initial_processor.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class InitialProcessor < Base
include HasPrometheusMetrics
attr_accessor :send_result
def process
logger.tagged(original_queued_message: @queued_message.id) do
logger.info "starting message unqueue"
begin
catch_stops do
increment_dequeue_metric
check_message_exists
check_message_is_ready
find_other_messages_for_batch
# Process the original message and then all of those
# found for batching.
process_message(@queued_message)
@other_messages&.each { |message| process_message(message) }
end
ensure
@state.finished
end
logger.info "finished message unqueue"
end
end
private
def increment_dequeue_metric
time_in_queue = Time.now.to_f - @queued_message.created_at.to_f
log "queue latency is #{time_in_queue}s"
observe_prometheus_histogram :postal_message_queue_latency,
time_in_queue
end
def check_message_exists
return if @queued_message.message
log "unqueue because backend message has been removed."
remove_from_queue
stop_processing
end
def check_message_is_ready
return if @queued_message.ready?
log "skipping because message isn't ready for processing"
@queued_message.unlock
stop_processing
end
def find_other_messages_for_batch
return unless Postal::Config.postal.batch_queued_messages?
@other_messages = @queued_message.batchable_messages(100)
log "found #{@other_messages.size} associated messages to process at the same time", batch_key: @queued_message.batch_key
rescue StandardError
@queued_message.unlock
raise
end
def process_message(queued_message)
logger.tagged(queued_message: queued_message.id) do
SingleMessageProcessor.process(queued_message, logger: @logger, state: @state)
end
end
end
end
================================================
FILE: app/lib/message_dequeuer/outgoing_message_processor.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class OutgoingMessageProcessor < Base
def process
catch_stops do
check_domain
check_rcpt_to
add_tag
hold_if_credential_is_set_to_hold
hold_if_recipient_on_suppression_list
parse_content
inspect_message
fail_if_spam
add_outgoing_headers
check_send_limits
increment_live_stats
hold_if_server_development_mode
send_message_to_sender
add_recipient_to_suppression_list_on_too_many_hard_fails
remove_recipient_from_suppression_list_on_success
log_sender_result
finish_processing
end
rescue StandardError => e
handle_exception(e)
end
private
def check_domain
return if queued_message.message.domain
log "message has no domain, hard failing"
create_delivery "HardFail", details: "Message's domain no longer exist"
remove_from_queue
stop_processing
end
def check_rcpt_to
return unless queued_message.message.rcpt_to.blank?
log "message has no 'to' address, hard failing"
create_delivery "HardFail", details: "Message doesn't have an RCPT to"
remove_from_queue
stop_processing
end
def add_tag
return if queued_message.message.tag
return unless tag = queued_message.message.headers["x-postal-tag"]
log "added tag: #{tag.last}"
queued_message.message.update(tag: tag.last)
end
def hold_if_credential_is_set_to_hold
return if queued_message.manual?
return if queued_message.message.credential.nil?
return unless queued_message.message.credential.hold?
log "credential wants us to hold messages, holding"
create_delivery "Held", details: "Credential is configured to hold all messages authenticated by it."
remove_from_queue
stop_processing
end
def hold_if_recipient_on_suppression_list
return if queued_message.manual?
return unless sl = queued_message.server.message_db.suppression_list.get(:recipient, queued_message.message.rcpt_to)
log "recipient is on the suppression list, holding"
create_delivery "Held", details: "Recipient (#{queued_message.message.rcpt_to}) is on the suppression list (reason: #{sl['reason']})"
remove_from_queue
stop_processing
end
def parse_content
return unless queued_message.message.should_parse?
log "parsing message content as it hasn't been parsed before"
queued_message.message.parse_content
end
def inspect_message
return if queued_message.message.inspected
return unless queued_message.server.outbound_spam_threshold
log "inspecting message"
queued_message.message.inspect_message
return unless queued_message.message.inspected
if queued_message.message.spam_score >= queued_message.server.outbound_spam_threshold
queued_message.message.update(spam: true)
end
log "message inspected successfully", spam: queued_message.message.spam?, spam_score: queued_message.message.spam_score
end
def fail_if_spam
return unless queued_message.message.spam
log "message is spam (#{queued_message.message.spam_score}), hard failing", server_threshold: queued_message.server.outbound_spam_threshold
create_delivery "HardFail",
details: "Message is likely spam. Threshold is #{queued_message.server.outbound_spam_threshold} and " \
"the message scored #{queued_message.message.spam_score}."
remove_from_queue
stop_processing
end
def add_outgoing_headers
return if queued_message.message.has_outgoing_headers?
queued_message.message.add_outgoing_headers
end
def check_send_limits
if queued_message.server.send_limit_exceeded?
# If we're over the limit, we're going to be holding this message
log "server send limit has been exceeded, holding", send_limit: queued_message.server.send_limit
queued_message.server.update_columns(send_limit_exceeded_at: Time.now, send_limit_approaching_at: nil)
create_delivery "Held", details: "Message held because send limit (#{queued_message.server.send_limit}) has been reached."
remove_from_queue
stop_processing
elsif queued_message.server.send_limit_approaching?
# If we're approaching the limit, just say we are but continue to process the message
queued_message.server.update_columns(send_limit_approaching_at: Time.now, send_limit_exceeded_at: nil)
else
queued_message.server.update_columns(send_limit_approaching_at: nil, send_limit_exceeded_at: nil)
end
end
def send_message_to_sender
@result = @state.send_result
return if @result
sender = @state.sender_for(SMTPSender,
queued_message.message.recipient_domain,
queued_message.ip_address)
@result = sender.send_message(queued_message.message)
return unless @result.connect_error
@state.send_result = @result
end
def add_recipient_to_suppression_list_on_too_many_hard_fails
return unless @result.type == "HardFail"
recent_hard_fails = queued_message.server.message_db.select(:messages,
where: {
rcpt_to: queued_message.message.rcpt_to,
status: "HardFail",
timestamp: { greater_than: 24.hours.ago.to_f }
},
count: true)
return if recent_hard_fails < 1
added = queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to,
reason: "too many hard fails")
return unless added
log "Added #{queued_message.message.rcpt_to} to suppression list because #{recent_hard_fails} hard fails in 24 hours"
@additional_delivery_details = "Recipient added to suppression list (too many hard fails)"
end
def remove_recipient_from_suppression_list_on_success
return unless @result.type == "Sent"
removed = queued_message.server.message_db.suppression_list.remove(:recipient, queued_message.message.rcpt_to)
return unless removed
log "removed #{queued_message.message.rcpt_to} from suppression list"
@additional_delivery_details = "Recipient removed from suppression list"
end
def finish_processing
if @result.retry
queued_message.retry_later(@result.retry.is_a?(Integer) ? @result.retry : nil)
log "message requeued for trying later", retry_after: queued_message.retry_after
stop_processing
end
log "message processing complete"
remove_from_queue
end
end
end
================================================
FILE: app/lib/message_dequeuer/single_message_processor.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class SingleMessageProcessor < Base
def process
catch_stops do
check_message_exists
check_server_suspension
check_delivery_attempts
check_raw_message_exists
processor = nil
case queued_message.message.scope
when "incoming"
processor = IncomingMessageProcessor
when "outgoing"
processor = OutgoingMessageProcessor
else
create_delivery "HardFail", details: "Scope #{queued_message.message.scope} is not valid"
remove_from_queue
stop_processing
end
processor.process(queued_message, logger: @logger, state: @state)
end
rescue StandardError => e
handle_exception(e)
end
private
def check_message_exists
return if queued_message.message
log "unqueueing because backend message has been removed"
remove_from_queue
stop_processing
end
def check_server_suspension
return unless queued_message.server.suspended?
log "server is suspended, holding message"
create_delivery "Held", details: "Mail server has been suspended. No e-mails can be processed at present. Contact support for assistance."
remove_from_queue
stop_processing
end
def check_delivery_attempts
return if queued_message.attempts < Postal::Config.postal.default_maximum_delivery_attempts
details = "Maximum number of delivery attempts (#{queued_message.attempts}) has been reached."
if queued_message.message.scope == "incoming"
# Send bounces to incoming e-mails when they are hard failed
if bounce_id = queued_message.send_bounce
details += " Bounce sent to sender (see message )"
end
elsif queued_message.message.scope == "outgoing"
# Add the recipient to the suppression list
if queued_message.server.message_db.suppression_list.add(:recipient, queued_message.message.rcpt_to, reason: "too many soft fails")
log "added #{queued_message.message.rcpt_to} to suppression list because maximum attempts has been reached"
details += " Added #{queued_message.message.rcpt_to} to suppression list because delivery has failed #{queued_message.attempts} times."
end
end
log "message has reached maximum number of attempts, hard failing"
create_delivery "HardFail", details: details
remove_from_queue
stop_processing
end
def check_raw_message_exists
return if queued_message.message.raw_message?
log "raw message has been removed, not sending"
create_delivery "HardFail", details: "Raw message has been removed. Cannot send message."
remove_from_queue
stop_processing
end
end
end
================================================
FILE: app/lib/message_dequeuer/state.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class State
attr_accessor :send_result
def sender_for(klass, *args, **kwargs)
@cached_senders ||= {}
@cached_senders[[klass, args, kwargs]] ||= begin
klass_instance = klass.new(*args, **kwargs)
klass_instance.start
klass_instance
end
end
def finished
@cached_senders&.each_value do |sender|
sender.finish
rescue StandardError
false
end
end
end
end
================================================
FILE: app/lib/message_dequeuer.rb
================================================
# frozen_string_literal: true
module MessageDequeuer
class << self
def process(message, logger:)
processor = InitialProcessor.new(message, logger: logger)
processor.process
end
end
end
================================================
FILE: app/lib/query_string.rb
================================================
# frozen_string_literal: true
class QueryString
def initialize(string)
@string = string.strip + " "
end
def [](value)
hash[value.to_s]
end
delegate :empty?, to: :hash
def hash
@hash ||= @string.scan(/([a-z]+):\s*(?:(\d{2,4}-\d{2}-\d{2}\s\d{2}:\d{2})|"(.*?)"|(.*?))(\s|\z)/).each_with_object({}) do |(key, date, string_with_spaces, value), hash|
if date
actual_value = date
elsif string_with_spaces
actual_value = string_with_spaces
elsif value == "[blank]"
actual_value = nil
else
actual_value = value
end
if hash.keys.include?(key.to_s)
hash[key.to_s] = [hash[key.to_s]].flatten
hash[key.to_s] << actual_value
else
hash[key.to_s] = actual_value
end
end
end
end
================================================
FILE: app/lib/received_header.rb
================================================
# frozen_string_literal: true
class ReceivedHeader
OUR_HOSTNAMES = {
smtp: Postal::Config.postal.smtp_hostname,
http: Postal::Config.postal.web_hostname
}.freeze
class << self
def generate(server, helo, ip_address, method)
our_hostname = OUR_HOSTNAMES[method]
if our_hostname.nil?
raise Error, "`method` is invalid (must be one of #{OUR_HOSTNAMES.join(', ')})"
end
header = "by #{our_hostname} with #{method.to_s.upcase}; #{Time.now.utc.rfc2822}"
if server.nil? || server.privacy_mode == false
hostname = DNSResolver.local.ip_to_hostname(ip_address)
header = "from #{helo} (#{hostname} [#{ip_address}]) #{header}"
end
header
end
end
end
================================================
FILE: app/lib/reply_separator.rb
================================================
# frozen_string_literal: true
class ReplySeparator
RULES = [
/^-{2,10} $.*/m,
/^>*\s*----- ?Original Message ?-----.*/m,
/^>*\s*From:[^\r\n]*[\r\n]+Sent:.*/m,
/^>*\s*From:[^\r\n]*[\r\n]+Date:.*/m,
/^>*\s*-----Urspr.ngliche Nachricht----- .*/m,
/^>*\s*Le[^\r\n]{10,200}a .crit ?:\s*$.*/,
/^>*\s*__________________.*/m,
/^>*\s*On.{10,200}wrote:\s*$.*/m,
/^>*\s*Sent from my.*/m,
/^>*\s*=== Please reply above this line ===.*/m,
/(^>.*\n?){10,}/,
].freeze
def self.separate(text)
return "" unless text.is_a?(String)
text = text.gsub("\r", "")
stripped = String.new
RULES.each do |rule|
text.gsub!(rule) do
stripped = ::Regexp.last_match(0).to_s + "\n" + stripped
""
end
end
stripped = stripped.strip
[text.strip, stripped.presence]
end
end
================================================
FILE: app/lib/smtp_client/endpoint.rb
================================================
# frozen_string_literal: true
module SMTPClient
class Endpoint
class SMTPSessionNotStartedError < StandardError
end
attr_reader :server
attr_reader :ip_address
attr_accessor :smtp_client
# @param server [Server] the server that this IP address is for
# @param ip_address [String] the IP address
def initialize(server, ip_address)
@server = server
@ip_address = ip_address
end
# Return a description of this server with its IP address
#
# @return [String]
def description
"#{@ip_address}:#{@server.port} (#{@server.hostname})"
end
# Return a string representation of this server
#
# @return [String]
def to_s
description
end
# Return true if this is an IPv6 address
#
# @return [Boolean]
def ipv6?
@ip_address.include?(":")
end
# Return true if this is an IPv4 address
#
# @return [Boolean]
def ipv4?
!ipv6?
end
# Start a new SMTP session and store the client with this server for future use as needed
#
# @param source_ip_address [IPAddress] the IP address to use as the source address for the connection
# @param allow_ssl [Boolean] whether to allow SSL for this connection, if false SSL mode is ignored
#
# @return [Net::SMTP]
def start_smtp_session(source_ip_address: nil, allow_ssl: true)
@smtp_client = Net::SMTP.new(@ip_address, @server.port)
@smtp_client.open_timeout = Postal::Config.smtp_client.open_timeout
@smtp_client.read_timeout = Postal::Config.smtp_client.read_timeout
@smtp_client.tls_hostname = @server.hostname
if source_ip_address
@source_ip_address = source_ip_address
end
if @source_ip_address
@smtp_client.source_address = ipv6? ? @source_ip_address.ipv6 : @source_ip_address.ipv4
end
if allow_ssl
case @server.ssl_mode
when SSLModes::AUTO
@smtp_client.enable_starttls_auto(self.class.ssl_context_without_verify)
when SSLModes::STARTTLS
@smtp_client.enable_starttls(self.class.ssl_context_with_verify)
when SSLModes::TLS
@smtp_client.enable_tls(self.class.ssl_context_with_verify)
else
@smtp_client.disable_starttls
@smtp_client.disable_tls
end
else
@smtp_client.disable_starttls
@smtp_client.disable_tls
end
@smtp_client.start(@source_ip_address ? @source_ip_address.hostname : self.class.default_helo_hostname)
@smtp_client
end
# Send a message to the current SMTP session (or create one if there isn't one for this endpoint).
# If sending messsage encouters some connection errors, retry again after re-establishing the SMTP
# session.
#
# @param raw_message [String] the raw message to send
# @param mail_from [String] the MAIL FROM address
# @param rcpt_to [String] the RCPT TO address
# @param retry_on_connection_error [Boolean] whether to retry the connection if there is a connection error
#
# @return [void]
def send_message(raw_message, mail_from, rcpt_to, retry_on_connection_error: true)
raise SMTPSessionNotStartedError if @smtp_client.nil? || (@smtp_client && !@smtp_client.started?)
@smtp_client.rset_errors
@smtp_client.send_message(raw_message, mail_from, [rcpt_to])
rescue Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError
if retry_on_connection_error
finish_smtp_session
start_smtp_session
return send_message(raw_message, mail_from, rcpt_to, retry_on_connection_error: false)
end
raise
end
# Reset the current SMTP session for this server if possible otherwise
# finish the session
#
# @return [void]
def reset_smtp_session
@smtp_client&.rset
rescue StandardError
finish_smtp_session
end
# Finish the current SMTP session for this server if possible.
#
# @return [void]
def finish_smtp_session
@smtp_client&.finish
rescue StandardError
nil
ensure
@smtp_client = nil
end
class << self
# Return the default HELO hostname to present to SMTP servers that
# we connect to
#
# @return [String]
def default_helo_hostname
Postal::Config.dns.helo_hostname ||
Postal::Config.postal.smtp_hostname ||
"localhost"
end
def ssl_context_with_verify
@ssl_context_with_verify ||= begin
c = OpenSSL::SSL::SSLContext.new
c.verify_mode = OpenSSL::SSL::VERIFY_PEER
c.cert_store = OpenSSL::X509::Store.new
c.cert_store.set_default_paths
c
end
end
def ssl_context_without_verify
@ssl_context_without_verify ||= begin
c = OpenSSL::SSL::SSLContext.new
c.verify_mode = OpenSSL::SSL::VERIFY_NONE
c
end
end
end
end
end
================================================
FILE: app/lib/smtp_client/server.rb
================================================
# frozen_string_literal: true
module SMTPClient
class Server
attr_reader :hostname
attr_reader :port
attr_accessor :ssl_mode
def initialize(hostname, port: 25, ssl_mode: SSLModes::AUTO)
@hostname = hostname
@port = port
@ssl_mode = ssl_mode
end
# Return all IP addresses for this server by resolving its hostname.
# IPv6 addresses will be returned first.
#
# @return [Array]
def endpoints
ips = []
DNSResolver.local.aaaa(@hostname).each do |ip|
ips << Endpoint.new(self, ip)
end
DNSResolver.local.a(@hostname).each do |ip|
ips << Endpoint.new(self, ip)
end
ips
end
end
end
================================================
FILE: app/lib/smtp_client/ssl_modes.rb
================================================
# frozen_string_literal: true
module SMTPClient
module SSLModes
AUTO = "Auto"
STARTTLS = "STARTLS"
TLS = "TLS"
NONE = "None"
end
end
================================================
FILE: app/lib/smtp_server/client.rb
================================================
# frozen_string_literal: true
module SMTPServer
class Client
extend HasPrometheusMetrics
include HasPrometheusMetrics
CRAM_MD5_DIGEST = OpenSSL::Digest.new("md5")
LOG_REDACTION_STRING = "[redacted]"
attr_reader :logging_enabled
attr_reader :credential
attr_reader :ip_address
attr_reader :recipients
attr_reader :headers
attr_reader :state
attr_reader :helo_name
def initialize(ip_address)
@logging_enabled = true
@ip_address = ip_address
@cr_present = false
@previous_cr_present = nil
if @ip_address
check_ip_address
@state = :welcome
else
@state = :preauth
end
transaction_reset
end
def check_ip_address
return unless @ip_address &&
Postal::Config.smtp_server.log_ip_address_exclusion_matcher &&
@ip_address =~ Regexp.new(Postal::Config.smtp_server.log_ip_address_exclusion_matcher)
@logging_enabled = false
end
def transaction_reset
@recipients = []
@mail_from = nil
@data = nil
@headers = nil
end
def trace_id
@trace_id ||= SecureRandom.alphanumeric(8).upcase
end
def handle(data)
if data[-1] == "\r"
@cr_present = true
data = data.chop # remove last character (\r)
else
# This doesn't use `logger` because that will be nil when logging is disabled
# and we always want to log this.
Postal.logger&.warn("Detected line with invalid line ending (missing )", trace_id: trace_id)
@cr_present = false
end
if @state == :preauth
return proxy(data)
end
logger&.debug "\e[32m<= #{sanitize_input_for_log(data.strip)}\e[0m"
if @proc
@proc.call(data)
else
handle_command(data)
end
ensure
@previous_cr_present = @cr_present
end
def finished?
@finished || false
end
def start_tls?
@start_tls || false
end
attr_writer :start_tls
def handle_command(data)
case data
when /^QUIT/i then quit
when /^STARTTLS/i then starttls
when /^EHLO/i then ehlo(data)
when /^HELO/i then helo(data)
when /^RSET/i then rset
when /^NOOP/i then noop
when /^AUTH PLAIN/i then auth_plain(data)
when /^AUTH LOGIN/i then auth_login(data)
when /^AUTH CRAM-MD5/i then auth_cram_md5(data)
when /^MAIL FROM/i then mail_from(data)
when /^RCPT TO/i then rcpt_to(data)
when /^DATA/i then data(data)
else
increment_error_count("invalid-command")
"502 Invalid/unsupported command"
end
end
def logger
return nil unless @logging_enabled
@logger ||= Postal.logger.create_tagged_logger(trace_id: trace_id)
end
private
def proxy(data)
# inet-protocol, client-ip, proxy-ip, client-port, proxy-port
if m = data.match(/\APROXY (.+) (.+) (.+) (.+) (.+)\z/)
@ip_address = m[2]
check_ip_address
@state = :welcome
logger&.debug "\e[35mClient identified as #{@ip_address}\e[0m"
increment_command_count("PROXY")
return "220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{trace_id}"
end
@finished = true
increment_error_count("proxy-error")
"502 Proxy Error"
end
def quit
@finished = true
"221 Closing Connection"
end
def starttls
if Postal::Config.smtp_server.tls_enabled?
@start_tls = true
@tls = true
increment_command_count("STARTLS")
"220 Ready to start TLS"
else
increment_error_count("tls-unavailable")
"502 TLS not available"
end
end
def ehlo(data)
@helo_name = data.strip.split(" ", 2)[1]
transaction_reset
@state = :welcomed
increment_command_count("EHLO")
[
"250-My capabilities are",
Postal::Config.smtp_server.tls_enabled? && !@tls ? "250-STARTTLS" : nil,
"250 AUTH CRAM-MD5 PLAIN LOGIN",
].compact
end
def helo(data)
@helo_name = data.strip.split(" ", 2)[1]
transaction_reset
@state = :welcomed
increment_command_count("HELO")
"250 #{Postal::Config.postal.smtp_hostname}"
end
def rset
transaction_reset
@state = :welcomed
increment_command_count("RSET")
"250 OK"
end
def noop
"250 OK"
end
def auth_plain(data)
increment_command_count("AUTH PLAIN")
handler = proc do |idata|
@proc = nil
idata = Base64.decode64(idata)
parts = idata.split("\0")
username = parts[-2]
password = parts[-1]
unless username && password
increment_error_count("missing-credentials")
next "535 Authenticated failed - protocol error"
end
authenticate(password)
end
data = data.gsub(/AUTH PLAIN ?/i, "")
if data.strip == ""
@proc = handler
@password_expected_next = true
"334"
else
handler.call(data)
end
end
def auth_login(data)
increment_command_count("AUTH LOGIN")
password_handler = proc do |idata|
@proc = nil
password = Base64.decode64(idata)
authenticate(password)
end
username_handler = proc do
@proc = password_handler
@password_expected_next = true
"334 UGFzc3dvcmQ6" # "Password:"
end
data = data.gsub(/AUTH LOGIN ?/i, "")
if data.strip == ""
@proc = username_handler
"334 VXNlcm5hbWU6" # "Username:"
else
username_handler.call(nil)
end
end
def authenticate(password)
if @credential = Credential.where(type: "SMTP", key: password).first
@credential.use
"235 Granted for #{@credential.server.organization.permalink}/#{@credential.server.permalink}"
else
logger&.warn "Authentication failure for #{@ip_address}"
increment_error_count("invalid-credentials")
"535 Invalid credential"
end
end
def auth_cram_md5(data)
increment_command_count("AUTH CRAM-MD5")
challenge = Digest::SHA1.hexdigest(Time.now.to_i.to_s + rand(100_000).to_s)
challenge = "<#{challenge[0, 20]}@#{Postal::Config.postal.smtp_hostname}>"
handler = proc do |idata|
@proc = nil
username, password = Base64.decode64(idata).split(" ", 2).map { |a| a.chomp }
org_permlink, server_permalink = username.split(/[\/_]/, 2)
server = ::Server.includes(:organization).where(organizations: { permalink: org_permlink }, permalink: server_permalink).first
if server.nil?
logger&.warn "Authentication failure for #{@ip_address} (no server found matching #{username})"
increment_error_count("invalid-credentials")
next "535 Denied"
end
grant = nil
server.credentials.where(type: "SMTP").each do |credential|
correct_response = OpenSSL::HMAC.hexdigest(CRAM_MD5_DIGEST, credential.key, challenge)
next unless password == correct_response
@credential = credential
@credential.use
logger&.debug "Authenticated with with credential #{credential.id}"
grant = "235 Granted for #{credential.server.organization.permalink}/#{credential.server.permalink}"
break
end
if grant.nil?
logger&.warn "Authentication failure for #{@ip_address} (invalid credential)"
increment_error_count("invalid-credentials")
next "535 Denied"
end
grant
end
@proc = handler
"334 " + Base64.encode64(challenge).gsub(/[\r\n]/, "")
end
def mail_from(data)
unless in_state(:welcomed, :mail_from_received)
increment_error_count("mail-from-out-of-order")
return "503 EHLO/HELO first please"
end
@state = :mail_from_received
transaction_reset
if data =~ /AUTH=/
# Discard AUTH= parameter and anything that follows.
# We don't need this parameter as we don't trust any client to set it
mail_from_line = data.sub(/ *AUTH=.*/, "")
else
mail_from_line = data
end
@mail_from = mail_from_line.gsub(/MAIL FROM\s*:\s*/i, "").gsub(/.*, "").gsub(/>.*/, "").strip
"250 OK"
end
def rcpt_to(data)
unless in_state(:mail_from_received, :rcpt_to_received)
increment_error_count("rcpt-to-out-of-order")
return "503 EHLO/HELO and MAIL FROM first please"
end
rcpt_to = data.gsub(/RCPT TO\s*:\s*/i, "").gsub(/.*, "").gsub(/>.*/, "").strip
if rcpt_to.blank?
increment_error_count("empty-rcpt-to")
return "501 RCPT TO should not be empty"
end
uname, domain = rcpt_to.split("@", 2)
if domain.blank?
increment_error_count("invalid-rcpt-to")
return "501 Invalid RCPT TO"
end
uname, tag = uname.split("+", 2)
if domain == Postal::Config.dns.return_path_domain || domain =~ /\A#{Regexp.escape(Postal::Config.dns.custom_return_path_prefix)}\./
# This is a return path
@state = :rcpt_to_received
if server = ::Server.where(token: uname).first
if server.suspended?
increment_error_count("server-suspended")
"535 Mail server has been suspended"
else
logger&.debug "Added bounce on server #{server.id}"
@recipients << [:bounce, rcpt_to, server]
"250 OK"
end
else
increment_error_count("invalid-server-token")
"550 Invalid server token"
end
elsif domain == Postal::Config.dns.route_domain
# This is an email direct to a route. This isn't actually supported yet.
@state = :rcpt_to_received
if route = Route.where(token: uname).first
if route.server.suspended?
increment_error_count("server-suspended")
"535 Mail server has been suspended"
elsif route.mode == "Reject"
increment_error_count("route-rejected")
"550 Route does not accept incoming messages"
else
logger&.debug "Added route #{route.id} to recipients (tag: #{tag.inspect})"
actual_rcpt_to = "#{route.name}#{tag ? "+#{tag}" : ''}@#{route.domain.name}"
@recipients << [:route, actual_rcpt_to, route.server, { route: route }]
"250 OK"
end
else
"550 Invalid route token"
end
elsif @credential
# This is outgoing mail for an authenticated user
@state = :rcpt_to_received
if @credential.server.suspended?
increment_error_count("server-suspended")
"535 Mail server has been suspended"
else
logger&.debug "Added external address '#{rcpt_to}'"
@recipients << [:credential, rcpt_to, @credential.server]
"250 OK"
end
elsif uname && domain && route = Route.find_by_name_and_domain(uname, domain)
# This is incoming mail for a route
@state = :rcpt_to_received
if route.server.suspended?
increment_error_count("server-suspended")
"535 Mail server has been suspended"
elsif route.mode == "Reject"
increment_error_count("route-rejection")
"550 Route does not accept incoming messages"
else
logger&.debug "Added route #{route.id} to recipients (tag: #{tag.inspect})"
@recipients << [:route, rcpt_to, route.server, { route: route }]
"250 OK"
end
else
# User is trying to relay but is not authenticated. Try to authenticate by IP address
@credential = Credential.where(type: "SMTP-IP").all.sort_by { |c| c.ipaddr&.prefix || 0 }.reverse.find do |credential|
credential.ipaddr.include?(@ip_address) || (credential.ipaddr.ipv4? && credential.ipaddr.ipv4_mapped.include?(@ip_address))
end
if @credential
# Retry with credential
@credential.use
rcpt_to(data)
else
increment_error_count("authentication-required")
logger&.warn "Authentication failure for #{@ip_address}"
"530 Authentication required"
end
end
end
def data(_data)
unless in_state(:rcpt_to_received)
increment_error_count("data-out-of-order")
return "503 HELO/EHLO, MAIL FROM and RCPT TO before sending data"
end
@data = String.new.force_encoding("BINARY")
@headers = {}
@receiving_headers = true
received_header = ReceivedHeader.generate(@credential&.server, @helo_name, @ip_address, :smtp)
.force_encoding("BINARY")
@data << "Received: #{received_header}\r\n"
@headers["received"] = [received_header]
handler = proc do |idata|
if idata == "." && @cr_present && @previous_cr_present
@logging_enabled = true
@proc = nil
finished
else
idata = idata.to_s.sub(/\A\.\./, ".")
if @credential&.server&.log_smtp_data?
# We want to log if enabled
else
logger&.debug "Not logging further message data."
@logging_enabled = false
end
if @receiving_headers
if idata&.length&.zero?
@receiving_headers = false
elsif idata.to_s =~ /^\s/
# This is a continuation of a header
if @header_key && @headers[@header_key.downcase] && @headers[@header_key.downcase].last
@headers[@header_key.downcase].last << idata.to_s
end
else
@header_key, value = idata.split(/:\s*/, 2)
@headers[@header_key.downcase] ||= []
@headers[@header_key.downcase] << value
end
end
@data << idata
@data << "\r\n"
nil
end
end
@proc = handler
"354 Go ahead"
end
def finished
if @data.bytesize > Postal::Config.smtp_server.max_message_size.megabytes.to_i
transaction_reset
@state = :welcomed
increment_error_count("message-too-large")
return format("552 Message too large (maximum size %dMB)", Postal::Config.smtp_server.max_message_size)
end
if @headers["received"].grep(/by #{Postal::Config.postal.smtp_hostname}/).count > 4
transaction_reset
@state = :welcomed
increment_error_count("loop-detected")
return "550 Loop detected"
end
authenticated_domain = nil
if @credential
authenticated_domain = @credential.server.find_authenticated_domain_from_headers(@headers)
if authenticated_domain.nil?
transaction_reset
@state = :welcomed
increment_error_count("from-name-invalid")
return "530 From/Sender name is not valid"
end
end
@recipients.each do |recipient|
type, rcpt_to, server, options = recipient
case type
when :credential
increment_message_count("outgoing")
# Outgoing messages are just inserted
message = server.message_db.new_message
message.rcpt_to = rcpt_to
message.mail_from = @mail_from
message.raw_message = @data
message.received_with_ssl = @tls
message.scope = "outgoing"
message.domain_id = authenticated_domain&.id
message.credential_id = @credential.id
message.save
when :bounce
increment_message_count("bounce")
if rp_route = server.routes.where(name: "__returnpath__").first
# If there's a return path route, we can use this to create the message
rp_route.create_messages do |msg|
msg.rcpt_to = rcpt_to
msg.mail_from = @mail_from
msg.raw_message = @data
msg.received_with_ssl = @tls
msg.bounce = 1
end
else
# There's no return path route, we just need to insert the mesage
# without going through the route.
message = server.message_db.new_message
message.rcpt_to = rcpt_to
message.mail_from = @mail_from
message.raw_message = @data
message.received_with_ssl = @tls
message.scope = "incoming"
message.bounce = 1
message.save
end
when :route
increment_message_count("incoming")
options[:route].create_messages do |msg|
msg.rcpt_to = rcpt_to
msg.mail_from = @mail_from
msg.raw_message = @data
msg.received_with_ssl = @tls
end
end
end
transaction_reset
@state = :welcomed
"250 OK"
end
def in_state(*states)
states.include?(@state)
end
def sanitize_input_for_log(data)
if @password_expected_next
@password_expected_next = false
if data =~ /\A[a-z0-9]{3,}=*\z/i
return LOG_REDACTION_STRING
end
end
data = data.dup
data.gsub!(/(.*AUTH \w+) (.*)\z/i) { "#{::Regexp.last_match(1)} #{LOG_REDACTION_STRING}" }
data
end
def increment_error_count(error)
increment_prometheus_counter :postal_smtp_server_client_errors, labels: { error: error }
end
def increment_command_count(command)
increment_prometheus_counter :postal_smtp_server_commands_total, labels: { command: command }
end
def increment_message_count(type)
increment_prometheus_counter :postal_smtp_server_messages_total, labels: {
type: type,
tls: @tls ? "yes" : "no"
}
end
class << self
def register_prometheus_metrics
register_prometheus_counter :postal_smtp_server_commands_total,
docstring: "The number of key commands received by the server",
labels: [:command]
register_prometheus_counter :postal_smtp_server_client_errors,
docstring: "The number of errors sent to a client",
labels: [:error]
register_prometheus_counter :postal_smtp_server_messages_total,
docstring: "The number of messages accepted by the SMTP server",
labels: [:type, :tls]
end
end
end
end
================================================
FILE: app/lib/smtp_server/server.rb
================================================
# frozen_string_literal: true
require "ipaddr"
require "nio"
module SMTPServer
class Server
include HasPrometheusMetrics
class << self
def tls_private_key
@tls_private_key ||= OpenSSL::PKey.read(File.read(Postal::Config.smtp_server.tls_private_key_path))
end
def tls_certificates
@tls_certificates ||= begin
data = File.read(Postal::Config.smtp_server.tls_certificate_path)
certs = data.scan(/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m)
certs.map do |c|
OpenSSL::X509::Certificate.new(c)
end.freeze
end
end
end
def initialize(options = {})
@options = options
@options[:debug] ||= false
register_prometheus_metrics
prepare_environment
end
def run
logger.tagged(component: "smtp-server") do
listen
run_event_loop
end
end
private
def prepare_environment
$\ = "\r\n"
BasicSocket.do_not_reverse_lookup = true
trap("TERM") do
$stdout.puts "Received TERM signal, shutting down."
unlisten
end
trap("INT") do
$stdout.puts "Received INT signal, shutting down."
unlisten
end
end
def ssl_context
@ssl_context ||= begin
ssl_context = OpenSSL::SSL::SSLContext.new
ssl_context.cert = self.class.tls_certificates[0]
ssl_context.extra_chain_cert = self.class.tls_certificates[1..]
ssl_context.key = self.class.tls_private_key
ssl_context.ssl_version = Postal::Config.smtp_server.ssl_version if Postal::Config.smtp_server.ssl_version
ssl_context.ciphers = Postal::Config.smtp_server.tls_ciphers if Postal::Config.smtp_server.tls_ciphers
ssl_context
end
end
def listen
bind_address = ENV.fetch("BIND_ADDRESS", Postal::Config.smtp_server.default_bind_address)
port = ENV.fetch("PORT", Postal::Config.smtp_server.default_port)
@server = TCPServer.open(bind_address, port)
@server.autoclose = false
@server.close_on_exec = false
if defined?(Socket::SOL_SOCKET) && defined?(Socket::SO_KEEPALIVE)
@server.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
end
if defined?(Socket::SOL_TCP) && defined?(Socket::TCP_KEEPIDLE) && defined?(Socket::TCP_KEEPINTVL) && defined?(Socket::TCP_KEEPCNT)
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 50)
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
@server.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 5)
end
logger.info "Listening on #{bind_address}:#{port}"
end
def unlisten
# Instruct the nio loop to unlisten and wake it
@unlisten = true
@io_selector.wakeup
end
def run_event_loop
# Set up an instance of nio4r to monitor for connections and data
@io_selector = NIO::Selector.new
# Register the SMTP listener
@io_selector.register(@server, :r)
# Create a hash to contain a buffer for each client.
buffers = Hash.new { |h, k| h[k] = String.new.force_encoding("BINARY") }
loop do
# Wait for an event to occur
@io_selector.select do |monitor|
# Get the IO from the nio monitor
io = monitor.io
# Is this event an incoming connection?
if io.is_a?(TCPServer)
begin
# Accept the connection
new_io = io.accept
increment_prometheus_counter :postal_smtp_server_connections_total
# Get the client's IP address and strip `::ffff:` for consistency.
client_ip_address = new_io.remote_address.ip_address.sub(/\A::ffff:/, "")
if Postal::Config.smtp_server.proxy_protocol?
# If we are using the haproxy proxy protocol, we will be sent the
# client's IP later. Delay the welcome process.
client = Client.new(nil)
if Postal::Config.smtp_server.log_connections?
client.logger&.debug "Connection opened from #{client_ip_address}"
end
else
# We're not using the proxy protocol so we already know the client's IP
client = Client.new(client_ip_address)
if Postal::Config.smtp_server.log_connections?
client.logger&.debug "Connection opened from #{client_ip_address}"
end
# We know who the client is, welcome them.
client.logger&.debug "Client identified as #{client_ip_address}"
new_io.print("220 #{Postal::Config.postal.smtp_hostname} ESMTP Postal/#{client.trace_id}")
end
# Register the client and its socket with nio4r
monitor = @io_selector.register(new_io, :r)
monitor.value = client
rescue StandardError => e
# If something goes wrong, log as appropriate and disconnect the client
if defined?(Sentry)
Sentry.capture_exception(e, extra: { trace_id: begin
client.trace_id
rescue StandardError
nil
end })
end
logger.error "An error occurred while accepting a new client."
logger.error "#{e.class}: #{e.message}"
e.backtrace.each do |line|
logger.error line
end
increment_prometheus_counter :postal_smtp_server_exceptions_total,
labels: { error: e.class.to_s, type: "client-accept" }
begin
new_io.close
rescue StandardError
nil
end
end
else
# This event is not an incoming connection so it must be data from a client
begin
# Get the client from the nio monitor
client = monitor.value
# For now we assume the connection isn't closed
eof = false
# Is the client negotiating a TLS handshake?
if client.start_tls?
begin
# Can we accept the TLS connection at this time?
io.accept_nonblock
# Increment prometheus
increment_prometheus_counter :postal_smtp_server_tls_connections_total
# We were able to accept the connection, the client is no longer handshaking
client.start_tls = false
rescue IO::WaitReadable, IO::WaitWritable => e
# Could not accept without blocking
# We will try again later
next
rescue OpenSSL::SSL::SSLError => e
client.logger&.debug "SSL Negotiation Failed: #{e.message}"
eof = true
end
else
# The client is not negotiating a TLS handshake at this time
begin
# Read 10kiB of data at a time from the socket.
buffers[io] << io.readpartial(10_240)
# There is an extra step for SSL sockets
if io.is_a?(OpenSSL::SSL::SSLSocket)
buffers[io] << io.readpartial(10_240) while io.pending.positive?
end
rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT
# Client went away
eof = true
end
# We line buffer, so look to see if we have received a newline
# and keep doing so until all buffered lines have been processed.
while buffers[io].index("\n")
# Extract the line
line, buffers[io] = buffers[io].split("\n", 2)
# Send the received line to the client object for processing
result = client.handle(line)
# If the client object returned some data, write it back to the client
next if result.nil?
result = [result] unless result.is_a?(Array)
result.compact.each do |iline|
client.logger&.debug "\e[34m=> #{iline.strip}\e[0m"
begin
io.write(iline.to_s + "\r\n")
io.flush
rescue Errno::ECONNRESET
# Client disconnected before we could write response
eof = true
end
end
end
# Did the client request STARTTLS?
if !eof && client.start_tls?
# Deregister the unencrypted IO
@io_selector.deregister(io)
buffers.delete(io)
io = OpenSSL::SSL::SSLSocket.new(io, ssl_context)
# Close the underlying IO when the TLS socket is closed
io.sync_close = true
# Register the new TLS socket with nio
monitor = @io_selector.register(io, :r)
monitor.value = client
end
end
# Has the client requested we close the connection?
if client.finished? || eof
client.logger&.debug "Connection closed"
# Deregister the socket and close it
@io_selector.deregister(io)
buffers.delete(io)
io.close
# If we have no more clients or listeners left, exit the process
if @io_selector.empty?
Process.exit(0)
end
end
rescue StandardError => e
# Something went wrong, log as appropriate
client_id = client ? client.trace_id : "------"
if defined?(Sentry)
Sentry.capture_exception(e, extra: { trace_id: begin
client&.trace_id
rescue StandardError
nil
end })
end
logger.error "An error occurred while processing data from a client.", trace_id: client_id
logger.error "#{e.class}: #{e.message}", trace_id: client_id
e.backtrace.each do |iline|
logger.error iline, trace_id: client_id
end
increment_prometheus_counter :postal_smtp_server_exceptions_total,
labels: { error: e.class.to_s, type: "data" }
# Close all IO and forget this client
begin
@io_selector.deregister(io)
rescue StandardError
nil
end
buffers.delete(io)
begin
io.close
rescue StandardError
nil
end
if @io_selector.empty?
Process.exit(0)
end
end
end
end
# If unlisten has been called, stop listening
next unless @unlisten
@io_selector.deregister(@server)
@server.close
# If there's nothing left to do, shut down the process
if @io_selector.empty?
Process.exit(0)
end
# Clear the request
@unlisten = false
end
end
def logger
Postal.logger
end
def register_prometheus_metrics
register_prometheus_counter :postal_smtp_server_connections_total,
docstring: "The number of connections made to the Postal SMTP server."
register_prometheus_counter :postal_smtp_server_exceptions_total,
docstring: "The number of server exceptions encountered by the SMTP server",
labels: [:type, :error]
register_prometheus_counter :postal_smtp_server_tls_connections_total,
docstring: "The number of successfuly TLS connections established"
Client.register_prometheus_metrics
end
end
end
================================================
FILE: app/lib/worker/jobs/base_job.rb
================================================
# frozen_string_literal: true
module Worker
module Jobs
class BaseJob
def initialize(logger:)
@logger = logger
end
def call
# Override me.
end
def work_completed?
@work_completed == true
end
private
def work_completed!
@work_completed = true
end
attr_reader :logger
end
end
end
================================================
FILE: app/lib/worker/jobs/process_queued_messages_job.rb
================================================
# frozen_string_literal: true
module Worker
module Jobs
class ProcessQueuedMessagesJob < BaseJob
def call
@lock_time = Time.current
@locker = Postal.locker_name_with_suffix(SecureRandom.hex(8))
find_ip_addresses
lock_message_for_processing
obtain_locked_messages
process_messages
@messages_to_process
end
private
# Returns an array of IP address IDs that are present on the host that is
# running this job.
#
# @return [Array]
def find_ip_addresses
ip_addresses = { 4 => [], 6 => [] }
Socket.ip_address_list.each do |address|
next if local_ip?(address.ip_address)
ip_addresses[address.ipv4? ? 4 : 6] << address.ip_address
end
@ip_addresses = IPAddress.where(ipv4: ip_addresses[4]).or(IPAddress.where(ipv6: ip_addresses[6])).pluck(:id)
end
# Is the given IP address a local address?
#
# @param [String] ip
# @return [Boolean]
def local_ip?(ip)
!!(ip =~ /\A(127\.|fe80:|::)/)
end
# Obtain a queued message from the database for processing
#
# @return [void]
def lock_message_for_processing
QueuedMessage.where(ip_address_id: [nil, @ip_addresses])
.where(locked_by: nil, locked_at: nil)
.ready_with_delayed_retry
.limit(1)
.update_all(locked_by: @locker, locked_at: @lock_time)
end
# Get a full list of all messages which we can process (i.e. those which have just
# been locked by us for processing)
#
# @return [void]
def obtain_locked_messages
@messages_to_process = QueuedMessage.where(locked_by: @locker, locked_at: @lock_time)
end
# Process the messages we obtained from the database
#
# @return [void]
def process_messages
@messages_to_process.each do |message|
work_completed!
MessageDequeuer.process(message, logger: logger)
end
end
end
end
end
================================================
FILE: app/lib/worker/jobs/process_webhook_requests_job.rb
================================================
# frozen_string_literal: true
module Worker
module Jobs
class ProcessWebhookRequestsJob < BaseJob
def call
@lock_time = Time.current
@locker = Postal.locker_name_with_suffix(SecureRandom.hex(8))
lock_request_for_processing
obtain_locked_requests
process_requests
end
private
# Obtain a webhook request from the database for processing
#
# @return [void]
def lock_request_for_processing
WebhookRequest.unlocked
.ready
.limit(1)
.update_all(locked_by: @locker, locked_at: @lock_time)
end
# Get a full list of all webhooks which we can process (i.e. those which have just
# been locked by us for processing)
#
# @return [void]
def obtain_locked_requests
@requests_to_process = WebhookRequest.where(locked_by: @locker, locked_at: @lock_time)
end
# Process the webhook requests we obtained from the database
#
# @return [void]
def process_requests
@requests_to_process.each do |request|
work_completed!
WebhookDeliveryService.new(webhook_request: request).call
end
end
end
end
end
================================================
FILE: app/lib/worker/process.rb
================================================
# frozen_string_literal: true
module Worker
# The Postal Worker process is responsible for handling all background tasks. This includes processing of all
# messages, webhooks and other administrative tasks. There are two main types of background work which is completed,
# jobs and scheduled tasks.
#
# The 'Jobs' here allow for the continuous monitoring of a database table (or queue) and processing of any new items
# which may appear in that. The polling takes place every 5 seconds by default and the work is able to run multiple
# threads to look for and process this work.
#
# Scheduled Tasks allow for code to be executed on a ROUGH schedule. This is used for administrative tasks. A single
# thread will run within each worker process and attempt to acquire the 'tasks' role. If successful it will run all
# tasks which are due to be run. The tasks are then scheduled to run again at a future time. Workers which are not
# successful in acquiring the role will not run any tasks but will still attempt to acquire a lock in case the current
# acquiree disappears.
#
# The worker process will run until it receives a TERM or INT signal. It will then attempt to gracefully shut down
# after it has completed any outstanding jobs which are already inflight.
class Process
include HasPrometheusMetrics
# An array of job classes that should be processed each time the worker ticks.
#
# @return [Array]
JOBS = [
Jobs::ProcessQueuedMessagesJob,
Jobs::ProcessWebhookRequestsJob,
].freeze
# An array of tasks that should be processed
#
# @return [Array]
TASKS = [
ActionDeletionsScheduledTask,
CheckAllDNSScheduledTask,
CleanupAuthieSessionsScheduledTask,
ExpireHeldMessagesScheduledTask,
ProcessMessageRetentionScheduledTask,
PruneSuppressionListsScheduledTask,
PruneWebhookRequestsScheduledTask,
SendNotificationsScheduledTask,
TidyQueuedMessagesTask,
].freeze
# @param [Integer] thread_count The number of worker threads to run in this process
def initialize(thread_count: Postal::Config.worker.threads,
work_sleep_time: 5,
task_sleep_time: 60)
@thread_count = thread_count
@exit_pipe_read, @exit_pipe_write = IO.pipe
@work_sleep_time = work_sleep_time
@task_sleep_time = task_sleep_time
@threads = []
setup_prometheus
end
def run
logger.tagged(component: "worker") do
setup_traps
ensure_connection_pool_size_is_suitable
start_work_threads
start_tasks_thread
wait_for_threads
end
end
private
# Install signal traps to allow for graceful shutdown
#
# @return [void]
def setup_traps
trap("INT") { receive_signal("INT") }
trap("TERM") { receive_signal("TERM") }
end
# Receive a signal and set the shutdown flag
#
# @param [String] signal The signal that was received z
# @return [void]
def receive_signal(signal)
puts "Received #{signal} signal. Stopping when able."
@shutdown = true
@exit_pipe_write.close
end
# Wait for the period of time and return true or false if shutdown has been requested. If the shutdown is
# requested during the wait, it will return immediately otherwise it will return false when it has finished
# waiting for the period of time.
#
# @param [Integer] wait_time The time to wait for
# @return [Boolean]
def shutdown_after_wait?(wait_time)
@exit_pipe_read.wait_readable(wait_time) ? true : false
end
# Ensure that the connection pool is big enough for the number of threads
# configured.
#
# @return [void]
def ensure_connection_pool_size_is_suitable
current_pool_size = ActiveRecord::Base.connection_pool.size
desired_pool_size = @thread_count + 3
return if current_pool_size >= desired_pool_size
logger.warn "number of worker threads (#{@thread_count}) is more " \
"than the db connection pool size (#{current_pool_size}+3), " \
"increasing connection pool size to #{desired_pool_size}"
Postal.change_database_connection_pool_size(desired_pool_size)
end
# Wait for all threads to complete
#
# @return [void]
def wait_for_threads
@threads.each(&:join)
end
# Start the worker threads
#
# @return [void]
def start_work_threads
logger.info "starting #{@thread_count} work threads"
@thread_count.times do |index|
start_work_thread(index)
end
end
# Start a worker thread
#
# @return [void]
def start_work_thread(index)
@threads << Thread.new do
logger.tagged(component: "worker", thread: "work#{index}") do
logger.info "started work thread #{index}"
loop do
work_completed = work(index)
if shutdown_after_wait?(work_completed ? 0 : @work_sleep_time)
break
end
end
logger.info "stopping work thread #{index}"
end
end
end
# Actually perform the work for this tick. This will call each job which has been registered.
#
# @return [Boolean] Whether any work was completed in this job or not
def work(thread)
completed_work = 0
ActiveRecord::Base.connection_pool.with_connection do
JOBS.each do |job_class|
capture_errors do
job = job_class.new(logger: logger)
time = Benchmark.realtime { job.call }
observe_prometheus_histogram :postal_worker_job_runtime,
time,
labels: {
thread: thread,
job: job_class.to_s.split("::").last
}
if job.work_completed?
completed_work += 1
increment_prometheus_counter :postal_worker_job_executions,
labels: {
thread: thread,
job: job_class.to_s.split("::").last
}
end
end
end
end
completed_work.positive?
end
# Start the tasks thread
#
# @return [void]
def start_tasks_thread
logger.info "starting tasks thread"
@threads << Thread.new do
logger.tagged(component: "worker", thread: "tasks") do
loop do
run_tasks
if shutdown_after_wait?(@task_sleep_time)
break
end
end
logger.info "stopping tasks thread"
ActiveRecord::Base.connection_pool.with_connection do
if WorkerRole.release(:tasks)
logger.info "released tasks role"
end
end
end
end
end
# Run the tasks. This will attempt to acquire the tasks role and if successful it will all the registered
# tasks if they are due to be run.
#
# @return [void]
def run_tasks
role_acquisition_status = ActiveRecord::Base.connection_pool.with_connection do
WorkerRole.acquire(:tasks)
end
case role_acquisition_status
when :stolen
logger.info "acquired task role by stealing it from a lazy worker"
when :created
logger.info "acquired task role by creating it"
when :renewed
logger.debug "acquired task role by renewing it"
else
logger.debug "could not acquire task role, not doing anything"
return false
end
ActiveRecord::Base.connection_pool.with_connection do
TASKS.each { |task| run_task(task) }
end
end
# Run a single task
#
# @param [Class] task The task to run
# @return [void]
def run_task(task)
logger.tagged task: task do
scheduled_task = ScheduledTask.find_by(name: task.to_s)
if scheduled_task.nil?
logger.info "no existing task object, creating it now"
scheduled_task = ScheduledTask.create!(name: task.to_s, next_run_after: task.next_run_after)
end
next unless scheduled_task.next_run_after < Time.current
logger.info "running task"
time = 0
capture_errors do
time = Benchmark.realtime do
task.new(logger: logger).call
end
observe_prometheus_histogram :postal_worker_task_runtime,
time,
labels: {
task: task.to_s.split("::").last
}
end
next_run_after = task.next_run_after
logger.info "scheduling task to next run at #{next_run_after}"
scheduled_task.update!(next_run_after: next_run_after)
end
end
# Return the logger
#
# @return [Klogger::Logger]
def logger
Postal.logger
end
# Capture exceptions and handle this as appropriate.
#
# @yield The block of code to run
# @return [void]
def capture_errors
yield
rescue StandardError => e
logger.error "#{e.class} (#{e.message})"
e.backtrace.each { |line| logger.error line }
Sentry.capture_exception(e) if defined?(Sentry)
increment_prometheus_counter :postal_worker_errors,
labels: { error: e.class.to_s }
end
def setup_prometheus
register_prometheus_counter :postal_worker_job_executions,
docstring: "The number of jobs worked by a worker where work was completed",
labels: [:thread, :job]
register_prometheus_histogram :postal_worker_job_runtime,
docstring: "The time taken to process jobs (in seconds)",
labels: [:thread, :job]
register_prometheus_counter :postal_worker_errors,
docstring: "The number of errors encountered while processing jobs",
labels: [:error]
register_prometheus_histogram :postal_worker_task_runtime,
docstring: "The time taken to process tasks (in seconds)",
labels: [:task]
register_prometheus_histogram :postal_message_queue_latency,
docstring: "The length of time between a message being queued and being dequeued (in seconds)"
end
end
end
================================================
FILE: app/mailers/app_mailer.rb
================================================
# frozen_string_literal: true
class AppMailer < ApplicationMailer
def verify_domain(domain, email_address, user)
@domain = domain
@email_address = email_address
@user = user
mail to: email_address, subject: "Verify your ownership of #{@domain.name}"
end
def password_reset(user, return_to = nil)
@user = user
@return_to = return_to
mail to: @user.email_address, subject: "Reset your Postal password"
end
def server_send_limit_approaching(server)
@server = server
mail to: @server.organization.notification_addresses, subject: "[#{server.full_permalink}] Mail server is approaching its send limit"
end
def server_send_limit_exceeded(server)
@server = server
mail to: @server.organization.notification_addresses, subject: "[#{server.full_permalink}] Mail server has exceeded its send limit"
end
def server_suspended(server)
@server = server
mail to: @server.organization.notification_addresses, subject: "[#{server.full_permalink}] Your mail server has been suspended"
end
def test_message(recipient)
mail to: recipient, subject: "Postal SMTP Test Message"
end
end
================================================
FILE: app/mailers/application_mailer.rb
================================================
# frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base
default from: "#{Postal::Config.smtp.from_name} <#{Postal::Config.smtp.from_address}>"
layout false
end
================================================
FILE: app/models/additional_route_endpoint.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: additional_route_endpoints
#
# id :integer not null, primary key
# route_id :integer
# endpoint_type :string(255)
# endpoint_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class AdditionalRouteEndpoint < ApplicationRecord
belongs_to :route
belongs_to :endpoint, polymorphic: true
validate :validate_endpoint_belongs_to_server
validate :validate_wildcard
validate :validate_uniqueness
def self.find_by_endpoint(endpoint)
class_name, id = endpoint.split("#", 2)
unless Route::ENDPOINT_TYPES.include?(class_name)
raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
end
return unless uuid = class_name.constantize.find_by_uuid(id)
where(endpoint_type: class_name, endpoint_id: uuid).first
end
def _endpoint
"#{endpoint_type}##{endpoint.uuid}"
end
def _endpoint=(value)
if value && value =~ /\#/
class_name, id = value.split("#", 2)
unless Route::ENDPOINT_TYPES.include?(class_name)
raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
end
self.endpoint = class_name.constantize.find_by_uuid(id)
else
self.endpoint = nil
end
end
private
def validate_endpoint_belongs_to_server
return unless endpoint && endpoint&.server != route.server
errors.add :endpoint, :invalid
end
def validate_uniqueness
return unless endpoint == route.endpoint
errors.add :base, "You can only add an endpoint to a route once"
end
def validate_wildcard
return unless route.wildcard?
return unless endpoint_type == "SMTPEndpoint" || endpoint_type == "AddressEndpoint"
errors.add :base, "SMTP or address endpoints are not permitted on wildcard routes"
end
end
================================================
FILE: app/models/address_endpoint.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: address_endpoints
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# address :string(255)
# last_used_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
class AddressEndpoint < ApplicationRecord
include HasUUID
belongs_to :server
has_many :routes, as: :endpoint
has_many :additional_route_endpoints, dependent: :destroy, as: :endpoint
validates :address, presence: true, format: { with: /@/ }, uniqueness: { scope: [:server_id], message: "has already been added", case_sensitive: false }
before_destroy :update_routes
def mark_as_used
update_column(:last_used_at, Time.now)
end
def update_routes
routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
end
def description
address
end
def domain
address.split("@", 2).last
end
end
================================================
FILE: app/models/application_record.rb
================================================
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
self.inheritance_column = "sti_type"
nilify_blanks
end
================================================
FILE: app/models/bounce_message.rb
================================================
# frozen_string_literal: true
class BounceMessage
def initialize(server, message)
@server = server
@message = message
end
def raw_message
mail = Mail.new
mail.to = @message.mail_from
mail.from = "Mail Delivery Service <#{@message.route.description}>"
mail.subject = "Mail Delivery Failed (#{@message.subject})"
mail.text_part = body
mail.attachments["Original Message.eml"] = { mime_type: "message/rfc822", encoding: "quoted-printable", content: @message.raw_message }
mail.message_id = "<#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}>"
mail.to_s
end
def queue
message = @server.message_db.new_message
message.scope = "outgoing"
message.rcpt_to = @message.mail_from
message.mail_from = @message.route.description
message.domain_id = @message.domain&.id
message.raw_message = raw_message
message.bounce = true
message.bounce_for_id = @message.id
message.save
message.id
end
def postmaster_address
@server.postmaster_address || "postmaster@#{@message.domain&.name || Postal::Config.postal.web_hostname}"
end
private
def body
<<~BODY
This is the mail delivery service responsible for delivering mail to #{@message.route.description}.
The message you've sent cannot be delivered. Your original message is attached to this message.
For further assistance please contact #{postmaster_address}. Please include the details below to help us identify the issue.
Message Token: #{@message.token}@#{@server.token}
Orginal Message ID: #{@message.message_id}
Mail from: #{@message.mail_from}
Rcpt To: #{@message.rcpt_to}
BODY
end
end
================================================
FILE: app/models/concerns/.keep
================================================
================================================
FILE: app/models/concerns/has_authentication.rb
================================================
# frozen_string_literal: true
module HasAuthentication
extend ActiveSupport::Concern
included do
has_secure_password validations: false
validates :password, length: { minimum: 8, allow_blank: true }
validates :password, confirmation: { allow_blank: true }
validate :validate_password_presence
before_save :clear_password_reset_token_on_password_change
scope :with_password, -> { where.not(password_digest: nil) }
end
class_methods do
def authenticate(email_address, password)
user = find_by(email_address: email_address)
raise Postal::Errors::AuthenticationError, "InvalidEmailAddress" if user.nil?
raise Postal::Errors::AuthenticationError, "InvalidPassword" unless user.authenticate(password)
user
end
end
def authenticate_with_previous_password_first(unencrypted_password)
if password_digest_changed?
BCrypt::Password.new(password_digest_was).is_password?(unencrypted_password) && self
else
authenticate(unencrypted_password)
end
end
def begin_password_reset(return_to = nil)
if Postal::Config.oidc.enabled? && (oidc_uid.present? || password_digest.blank?)
raise Postal::Error, "User has OIDC enabled, password resets are not supported"
end
self.password_reset_token = SecureRandom.alphanumeric(24)
self.password_reset_token_valid_until = 1.day.from_now
save!
AppMailer.password_reset(self, return_to).deliver
end
private
def clear_password_reset_token_on_password_change
return unless password_digest_changed?
self.password_reset_token = nil
self.password_reset_token_valid_until = nil
end
def validate_password_presence
return if password_digest.present? || Postal::Config.oidc.enabled?
errors.add :password, :blank
end
end
# -*- SkipSchemaAnnotations
================================================
FILE: app/models/concerns/has_dns_checks.rb
================================================
# frozen_string_literal: true
require "resolv"
module HasDNSChecks
def dns_ok?
spf_status == "OK" && dkim_status == "OK" && %w[OK Missing].include?(mx_status) && %w[OK Missing].include?(return_path_status)
end
def dns_checked?
spf_status.present?
end
def check_dns(source = :manual)
check_spf_record
check_dkim_record
check_mx_records
check_return_path_record
self.dns_checked_at = Time.now
save!
if source == :auto && !dns_ok? && owner.is_a?(Server)
WebhookRequest.trigger(owner, "DomainDNSError", {
server: owner.webhook_hash,
domain: name,
uuid: uuid,
dns_checked_at: dns_checked_at.to_f,
spf_status: spf_status,
spf_error: spf_error,
dkim_status: dkim_status,
dkim_error: dkim_error,
mx_status: mx_status,
mx_error: mx_error,
return_path_status: return_path_status,
return_path_error: return_path_error
})
end
dns_ok?
end
#
# SPF
#
def check_spf_record
result = resolver.txt(name)
spf_records = result.grep(/\Av=spf1/)
if spf_records.empty?
self.spf_status = "Missing"
self.spf_error = "No SPF record exists for this domain"
else
suitable_spf_records = spf_records.grep(/include:\s*#{Regexp.escape(Postal::Config.dns.spf_include)}/)
if suitable_spf_records.empty?
self.spf_status = "Invalid"
self.spf_error = "An SPF record exists but it doesn't include #{Postal::Config.dns.spf_include}"
false
else
self.spf_status = "OK"
self.spf_error = nil
true
end
end
end
def check_spf_record!
check_spf_record
save!
end
#
# DKIM
#
def check_dkim_record
domain = "#{dkim_record_name}.#{name}"
records = resolver.txt(domain)
if records.empty?
self.dkim_status = "Missing"
self.dkim_error = "No TXT records were returned for #{domain}"
else
sanitised_dkim_record = records.first.strip.ends_with?(";") ? records.first.strip : "#{records.first.strip};"
if records.size > 1
self.dkim_status = "Invalid"
self.dkim_error = "There are #{records.size} records for at #{domain}. There should only be one."
elsif sanitised_dkim_record != dkim_record
self.dkim_status = "Invalid"
self.dkim_error = "The DKIM record at #{domain} does not match the record we have provided. Please check it has been copied correctly."
else
self.dkim_status = "OK"
self.dkim_error = nil
true
end
end
end
def check_dkim_record!
check_dkim_record
save!
end
#
# MX
#
def check_mx_records
records = resolver.mx(name).map(&:last)
if records.empty?
self.mx_status = "Missing"
self.mx_error = "There are no MX records for #{name}"
else
missing_records = Postal::Config.dns.mx_records.dup - records.map { |r| r.to_s.downcase }
if missing_records.empty?
self.mx_status = "OK"
self.mx_error = nil
elsif missing_records.size == Postal::Config.dns.mx_records.size
self.mx_status = "Missing"
self.mx_error = "You have MX records but none of them point to us."
else
self.mx_status = "Invalid"
self.mx_error = "MX #{missing_records.size == 1 ? 'record' : 'records'} for #{missing_records.to_sentence} are missing and are required."
end
end
end
def check_mx_records!
check_mx_records
save!
end
#
# Return Path
#
def check_return_path_record
records = resolver.cname(return_path_domain)
if records.empty?
self.return_path_status = "Missing"
self.return_path_error = "There is no return path record at #{return_path_domain}"
elsif records.size == 1 && records.first == Postal::Config.dns.return_path_domain
self.return_path_status = "OK"
self.return_path_error = nil
else
self.return_path_status = "Invalid"
self.return_path_error = "There is a CNAME record at #{return_path_domain} but it points to #{records.first} which is incorrect. It should point to #{Postal::Config.dns.return_path_domain}."
end
end
def check_return_path_record!
check_return_path_record
save!
end
end
# -*- SkipSchemaAnnotations
================================================
FILE: app/models/concerns/has_locking.rb
================================================
# frozen_string_literal: true
# This concern provides functionality for locking items along with additional functionality to handle
# the concept of retrying items after a certain period of time. The following database columns are
# required on the model
#
# * locked_by - A string column to store the name of the process that has locked the item
# * locked_at - A datetime column to store the time the item was locked
# * retry_after - A datetime column to store the time after which the item should be retried
# * attempts - An integer column to store the number of attempts that have been made to process the item
#
# 'ready' means that it's ready to be processed.
module HasLocking
extend ActiveSupport::Concern
included do
scope :unlocked, -> { where(locked_at: nil) }
scope :ready, -> { where("retry_after IS NULL OR retry_after < ?", Time.now) }
end
def ready?
retry_after.nil? || retry_after < Time.now
end
def unlock
self.locked_by = nil
self.locked_at = nil
update_columns(locked_by: nil, locked_at: nil)
end
def locked?
locked_at.present?
end
def retry_later(time = nil)
retry_time = time || calculate_retry_time(attempts, 5.minutes)
self.locked_by = nil
self.locked_at = nil
update_columns(locked_by: nil, locked_at: nil, retry_after: Time.now + retry_time, attempts: attempts + 1)
end
def calculate_retry_time(attempts, initial_period)
(1.3**attempts) * initial_period
end
end
================================================
FILE: app/models/concerns/has_message.rb
================================================
# frozen_string_literal: true
module HasMessage
def self.included(base)
base.extend ClassMethods
end
def message
return @message if instance_variable_defined?("@message")
@message = server.message_db.message(message_id)
rescue Postal::MessageDB::Message::NotFound
@message = nil
end
def message=(message)
@message = message
self.message_id = message&.id
end
module ClassMethods
def include_message
queued_messages = all.to_a
server_ids = queued_messages.map(&:server_id).uniq
if server_ids.empty?
return []
elsif server_ids.size > 1
raise Postal::Error, "'include_message' can only be used on collections of messages from the same server"
end
message_ids = queued_messages.map(&:message_id).uniq
server = queued_messages.first&.server
messages = server.message_db.messages(where: { id: message_ids }).index_by do |message|
message.id
end
queued_messages.each do |queued_message|
if m = messages[queued_message.message_id]
queued_message.message = m
end
end
end
end
end
================================================
FILE: app/models/concerns/has_soft_destroy.rb
================================================
# frozen_string_literal: true
module HasSoftDestroy
def self.included(base)
base.define_callbacks :soft_destroy
base.class_eval do
scope :deleted, -> { where.not(deleted_at: nil) }
scope :present, -> { where(deleted_at: nil) }
end
end
def soft_destroy
run_callbacks :soft_destroy do
self.deleted_at = Time.now
save!
end
end
end
================================================
FILE: app/models/concerns/has_uuid.rb
================================================
# frozen_string_literal: true
module HasUUID
def self.included(base)
base.class_eval do
random_string :uuid, type: :uuid, unique: true
end
end
def to_param
uuid
end
end
================================================
FILE: app/models/credential.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: credentials
#
# id :integer not null, primary key
# server_id :integer
# key :string(255)
# type :string(255)
# name :string(255)
# options :text(65535)
# last_used_at :datetime
# created_at :datetime
# updated_at :datetime
# hold :boolean default(FALSE)
# uuid :string(255)
#
class Credential < ApplicationRecord
include HasUUID
belongs_to :server
TYPES = %w[SMTP API SMTP-IP].freeze
validates :key, presence: true, uniqueness: { case_sensitive: false }
validates :type, inclusion: { in: TYPES }
validates :name, presence: true
validate :validate_key_cannot_be_changed
validate :validate_key_for_smtp_ip
serialize :options, type: Hash
before_validation :generate_key
def generate_key
return if type == "SMTP-IP"
return if persisted?
self.key = SecureRandom.alphanumeric(24)
end
def to_param
uuid
end
def use
update_column(:last_used_at, Time.now)
end
def usage_type
if last_used_at.nil?
"Unused"
elsif last_used_at < 1.year.ago
"Inactive"
elsif last_used_at < 6.months.ago
"Dormant"
elsif last_used_at < 1.month.ago
"Quiet"
else
"Active"
end
end
def to_smtp_plain
Base64.encode64("\0XX\0#{key}").strip
end
def ipaddr
return unless type == "SMTP-IP"
@ipaddr ||= IPAddr.new(key)
rescue IPAddr::InvalidAddressError
nil
end
private
def validate_key_cannot_be_changed
return if new_record?
return unless key_changed?
return if type == "SMTP-IP"
errors.add :key, "cannot be changed"
end
def validate_key_for_smtp_ip
return unless type == "SMTP-IP"
IPAddr.new(key.to_s)
rescue IPAddr::InvalidAddressError
errors.add :key, "must be a valid IPv4 or IPv6 address"
end
end
================================================
FILE: app/models/domain.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: domains
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# verification_token :string(255)
# verification_method :string(255)
# verified_at :datetime
# dkim_private_key :text(65535)
# created_at :datetime
# updated_at :datetime
# dns_checked_at :datetime
# spf_status :string(255)
# spf_error :string(255)
# dkim_status :string(255)
# dkim_error :string(255)
# mx_status :string(255)
# mx_error :string(255)
# return_path_status :string(255)
# return_path_error :string(255)
# outgoing :boolean default(TRUE)
# incoming :boolean default(TRUE)
# owner_type :string(255)
# owner_id :integer
# dkim_identifier_string :string(255)
# use_for_any :boolean
#
# Indexes
#
# index_domains_on_server_id (server_id)
# index_domains_on_uuid (uuid)
#
require "resolv"
class Domain < ApplicationRecord
include HasUUID
include HasDNSChecks
VERIFICATION_EMAIL_ALIASES = %w[webmaster postmaster admin administrator hostmaster].freeze
VERIFICATION_METHODS = %w[DNS Email].freeze
belongs_to :server, optional: true
belongs_to :owner, optional: true, polymorphic: true
has_many :routes, dependent: :destroy
has_many :track_domains, dependent: :destroy
validates :name, presence: true, format: { with: /\A[a-z0-9\-.]*\z/ }, uniqueness: { case_sensitive: false, scope: [:owner_type, :owner_id], message: "is already added" }
validates :verification_method, inclusion: { in: VERIFICATION_METHODS }
random_string :dkim_identifier_string, type: :chars, length: 6, unique: true, upper_letters_only: true
before_create :generate_dkim_key
scope :verified, -> { where.not(verified_at: nil) }
before_save :update_verification_token_on_method_change
def verified?
verified_at.present?
end
def mark_as_verified
return false if verified?
self.verified_at = Time.now
save!
end
def parent_domains
parts = name.split(".")
parts[0, parts.size - 1].each_with_index.map do |_, i|
parts[i..].join(".")
end
end
def generate_dkim_key
self.dkim_private_key = OpenSSL::PKey::RSA.new(1024).to_s
end
def dkim_key
return nil unless dkim_private_key
@dkim_key ||= OpenSSL::PKey::RSA.new(dkim_private_key)
end
def to_param
uuid
end
def verification_email_addresses
parent_domains.map do |domain|
VERIFICATION_EMAIL_ALIASES.map do |a|
"#{a}@#{domain}"
end
end.flatten
end
def spf_record
"v=spf1 a mx include:#{Postal::Config.dns.spf_include} ~all"
end
def dkim_record
return if dkim_key.nil?
public_key = dkim_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
end
def dkim_identifier
return nil unless dkim_identifier_string
Postal::Config.dns.dkim_identifier + "-#{dkim_identifier_string}"
end
def dkim_record_name
identifier = dkim_identifier
return if identifier.nil?
"#{identifier}._domainkey"
end
def return_path_domain
"#{Postal::Config.dns.custom_return_path_prefix}.#{name}"
end
# Returns a DNSResolver instance that can be used to perform DNS lookups needed for
# the verification and DNS checking for this domain.
#
# @return [DNSResolver]
def resolver
return DNSResolver.local if Postal::Config.postal.use_local_ns_for_domain_verification?
@resolver ||= DNSResolver.for_domain(name)
end
def dns_verification_string
"#{Postal::Config.dns.domain_verify_prefix} #{verification_token}"
end
def verify_with_dns
return false unless verification_method == "DNS"
result = resolver.txt(name)
if result.include?(dns_verification_string)
self.verified_at = Time.now
return save
end
false
end
private
def update_verification_token_on_method_change
return unless verification_method_changed?
if verification_method == "DNS"
self.verification_token = SecureRandom.alphanumeric(32)
elsif verification_method == "Email"
self.verification_token = rand(999_999).to_s.ljust(6, "0")
else
self.verification_token = nil
end
end
end
================================================
FILE: app/models/http_endpoint.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: http_endpoints
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# url :string(255)
# encoding :string(255)
# format :string(255)
# strip_replies :boolean default(FALSE)
# error :text(65535)
# disabled_until :datetime
# last_used_at :datetime
# created_at :datetime
# updated_at :datetime
# include_attachments :boolean default(TRUE)
# timeout :integer
#
class HTTPEndpoint < ApplicationRecord
DEFAULT_TIMEOUT = 5
include HasUUID
belongs_to :server
has_many :routes, as: :endpoint
has_many :additional_route_endpoints, dependent: :destroy, as: :endpoint
ENCODINGS = %w[BodyAsJSON FormData].freeze
FORMATS = %w[Hash RawMessage].freeze
before_destroy :update_routes
validates :name, presence: true
validates :url, presence: true
validates :encoding, inclusion: { in: ENCODINGS }
validates :format, inclusion: { in: FORMATS }
validates :timeout, numericality: { greater_than_or_equal_to: 5, less_than_or_equal_to: 60 }
default_value :timeout, -> { DEFAULT_TIMEOUT }
def description
"#{name} (#{url})"
end
def mark_as_used
update_column(:last_used_at, Time.now)
end
def update_routes
routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
end
end
================================================
FILE: app/models/incoming_message_prototype.rb
================================================
# frozen_string_literal: true
class IncomingMessagePrototype
attr_accessor :to
attr_accessor :from
attr_accessor :route_id
attr_accessor :subject
attr_accessor :plain_body
attr_accessor :attachments
def initialize(server, ip, source_type, attributes)
@server = server
@ip = ip
@source_type = source_type
@attachments = []
attributes.each do |key, value|
instance_variable_set("@#{key}", value)
end
end
def from_address
@from.gsub(/.*, "").gsub(/>.*/, "").strip
end
def route
@route ||= if @to.present?
uname, domain = @to.split("@", 2)
uname, _tag = uname.split("+", 2)
@server.routes.includes(:domain).where(domains: { name: domain }, name: uname).first
end
end
# rubocop:disable Lint/DuplicateMethods
def attachments
(@attachments || []).map do |attachment|
{
name: attachment[:name],
content_type: attachment[:content_type] || "application/octet-stream",
data: attachment[:base64] ? Base64.decode64(attachment[:data]) : attachment[:data]
}
end
end
# rubocop:enable Lint/DuplicateMethods
def create_messages
if valid?
messages = route.create_messages do |message|
message.rcpt_to = @to
message.mail_from = from_address
message.raw_message = raw_message
end
{ route.description => { id: messages.first.id, token: messages.first.token } }
else
false
end
end
def valid?
validate
errors.empty?
end
def errors
@errors || []
end
def validate
@errors = []
if route.nil?
@errors << "NoRoutesFound"
end
if from.empty?
@errors << "FromAddressMissing"
end
if subject.blank?
@errors << "SubjectMissing"
end
@errors
end
def raw_message
@raw_message ||= begin
mail = Mail.new
mail.to = @to
mail.from = @from
mail.subject = @subject
mail.text_part = @plain_body
mail.message_id = "<#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}>"
attachments.each do |attachment|
mail.attachments[attachment[:name]] = {
mime_type: attachment[:content_type],
content: attachment[:data]
}
end
mail.header["Received"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)
mail.to_s
end
end
end
================================================
FILE: app/models/ip_address.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: ip_addresses
#
# id :integer not null, primary key
# ip_pool_id :integer
# ipv4 :string(255)
# ipv6 :string(255)
# created_at :datetime
# updated_at :datetime
# hostname :string(255)
# priority :integer
#
class IPAddress < ApplicationRecord
belongs_to :ip_pool
validates :ipv4, presence: true, uniqueness: true
validates :hostname, presence: true
validates :ipv6, uniqueness: { allow_blank: true }
validates :priority, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, only_integer: true }
scope :order_by_priority, -> { order(priority: :desc) }
before_validation :set_default_priority
private
def set_default_priority
return if priority.present?
self.priority = 100
end
class << self
def select_by_priority
order(Arel.sql("RAND() * priority DESC")).first
end
end
end
================================================
FILE: app/models/ip_pool.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: ip_pools
#
# id :integer not null, primary key
# name :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# default :boolean default(FALSE)
#
# Indexes
#
# index_ip_pools_on_uuid (uuid)
#
class IPPool < ApplicationRecord
include HasUUID
validates :name, presence: true
has_many :ip_addresses, dependent: :restrict_with_exception
has_many :servers, dependent: :restrict_with_exception
has_many :organization_ip_pools, dependent: :destroy
has_many :organizations, through: :organization_ip_pools
has_many :ip_pool_rules, dependent: :destroy
def self.default
where(default: true).order(:id).first
end
end
================================================
FILE: app/models/ip_pool_rule.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: ip_pool_rules
#
# id :integer not null, primary key
# uuid :string(255)
# owner_type :string(255)
# owner_id :integer
# ip_pool_id :integer
# from_text :text(65535)
# to_text :text(65535)
# created_at :datetime not null
# updated_at :datetime not null
#
class IPPoolRule < ApplicationRecord
include HasUUID
belongs_to :owner, polymorphic: true
belongs_to :ip_pool
validate :validate_from_and_to_addresses
validate :validate_ip_pool_belongs_to_organization
def from
from_text ? from_text.gsub(/\r/, "").split(/\n/).map(&:strip) : []
end
def to
to_text ? to_text.gsub(/\r/, "").split(/\n/).map(&:strip) : []
end
def apply_to_message?(message)
if from.present? && message.headers["from"].present?
from.each do |condition|
if message.headers["from"].any? { |f| self.class.address_matches?(condition, f) }
return true
end
end
end
if to.present? && message.rcpt_to.present?
to.each do |condition|
if self.class.address_matches?(condition, message.rcpt_to)
return true
end
end
end
false
end
private
def validate_from_and_to_addresses
return unless from.empty? && to.empty?
errors.add :base, "At least one rule condition must be specified"
end
def validate_ip_pool_belongs_to_organization
org = owner.is_a?(Organization) ? owner : owner.organization
return unless ip_pool && ip_pool_id_changed? && !org.ip_pools.include?(ip_pool)
errors.add :ip_pool_id, "must belong to the organization"
end
class << self
def address_matches?(condition, address)
address = Postal::Helpers.strip_name_from_address(address)
if condition =~ /@/
parts = address.split("@")
domain = parts.pop
uname = parts.join("@")
uname, = uname.split("+", 2)
condition == "#{uname}@#{domain}"
else
# Match as a domain
condition == address.split("@").last
end
end
end
end
================================================
FILE: app/models/organization.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: organizations
#
# id :integer not null, primary key
# uuid :string(255)
# name :string(255)
# permalink :string(255)
# time_zone :string(255)
# created_at :datetime
# updated_at :datetime
# ip_pool_id :integer
# owner_id :integer
# deleted_at :datetime
# suspended_at :datetime
# suspension_reason :string(255)
#
# Indexes
#
# index_organizations_on_permalink (permalink)
# index_organizations_on_uuid (uuid)
#
class Organization < ApplicationRecord
RESERVED_PERMALINKS = %w[new edit remove delete destroy admin mail org server].freeze
INITIAL_QUOTA = 10
INITIAL_SUPER_QUOTA = 10_000
include HasUUID
include HasSoftDestroy
validates :name, presence: true
validates :permalink, presence: true, format: { with: /\A[a-z0-9-]*\z/ }, uniqueness: { case_sensitive: false }, exclusion: { in: RESERVED_PERMALINKS }
validates :time_zone, presence: true
default_value :time_zone, -> { "UTC" }
default_value :permalink, -> { Organization.find_unique_permalink(name) if name }
belongs_to :owner, class_name: "User"
has_many :organization_users, dependent: :destroy
has_many :users, through: :organization_users, source_type: "User"
has_many :user_invites, through: :organization_users, source_type: "UserInvite", source: :user
has_many :servers, dependent: :destroy
has_many :domains, as: :owner, dependent: :destroy
has_many :organization_ip_pools, dependent: :destroy
has_many :ip_pools, through: :organization_ip_pools
has_many :ip_pool_rules, dependent: :destroy, as: :owner
after_create do
if IPPool.default
ip_pools << IPPool.default
end
end
def status
if suspended?
"Suspended"
else
"Active"
end
end
def to_param
permalink
end
def suspended?
suspended_at.present?
end
def user_assignment(user)
@user_assignments ||= {}
@user_assignments[user.id] ||= organization_users.where(user: user).first
end
def make_owner(new_owner)
user_assignment(new_owner).update(admin: true, all_servers: true)
update(owner: new_owner)
end
# This is an array of addresses that should receive notifications for this organization
def notification_addresses
users.map(&:email_tag)
end
def self.find_unique_permalink(name)
loop.each_with_index do |_, i|
i += 1
proposal = name.parameterize
proposal += "-#{i}" if i > 1
unless where(permalink: proposal).exists?
return proposal
end
end
end
def self.[](id)
if id.is_a?(String)
where(permalink: id).first
else
where(id: id.to_i).first
end
end
end
================================================
FILE: app/models/organization_ip_pool.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: organization_ip_pools
#
# id :integer not null, primary key
# organization_id :integer
# ip_pool_id :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class OrganizationIPPool < ApplicationRecord
belongs_to :organization
belongs_to :ip_pool
end
================================================
FILE: app/models/organization_user.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: organization_users
#
# id :integer not null, primary key
# organization_id :integer
# user_id :integer
# created_at :datetime
# admin :boolean default(FALSE)
# all_servers :boolean default(TRUE)
# user_type :string(255)
#
class OrganizationUser < ApplicationRecord
belongs_to :organization
belongs_to :user, polymorphic: true, optional: true
end
================================================
FILE: app/models/outgoing_message_prototype.rb
================================================
# frozen_string_literal: true
require "resolv"
class OutgoingMessagePrototype
attr_accessor :from
attr_accessor :sender
attr_accessor :to
attr_accessor :cc
attr_accessor :bcc
attr_accessor :subject
attr_accessor :reply_to
attr_accessor :custom_headers
attr_accessor :plain_body
attr_accessor :html_body
attr_accessor :attachments
attr_accessor :tag
attr_accessor :credential
attr_accessor :bounce
def initialize(server, ip, source_type, attributes)
@server = server
@ip = ip
@source_type = source_type
@custom_headers = {}
@attachments = []
@message_id = "#{SecureRandom.uuid}@#{Postal::Config.dns.return_path_domain}"
attributes.each do |key, value|
instance_variable_set("@#{key}", value)
end
end
attr_reader :message_id
def from_address
Postal::Helpers.strip_name_from_address(@from)
end
def sender_address
Postal::Helpers.strip_name_from_address(@sender)
end
def domain
@domain ||= begin
d = find_domain
d == :none ? nil : d
end
end
def find_domain
domain = @server.authenticated_domain_for_address(@from)
if @server.allow_sender? && domain.nil?
domain = @server.authenticated_domain_for_address(@sender)
end
domain || :none
end
def to_addresses
@to.is_a?(String) ? @to.to_s.split(/,\s*/) : @to.to_a
end
def cc_addresses
@cc.is_a?(String) ? @cc.to_s.split(/,\s*/) : @cc.to_a
end
def bcc_addresses
@bcc.is_a?(String) ? @bcc.to_s.split(/,\s*/) : @bcc.to_a
end
def all_addresses
[to_addresses, cc_addresses, bcc_addresses].flatten
end
def create_messages
if valid?
all_addresses.each_with_object({}) do |address, hash|
if address = Postal::Helpers.strip_name_from_address(address)
hash[address] = create_message(address)
end
end
else
false
end
end
def valid?
validate
errors.empty?
end
def errors
@errors || {}
end
# rubocop:disable Lint/DuplicateMethods
def attachments
(@attachments || []).map do |attachment|
{
name: attachment[:name],
content_type: attachment[:content_type] || "application/octet-stream",
data: attachment[:base64] && attachment[:data] ? Base64.decode64(attachment[:data]) : attachment[:data]
}
end
end
# rubocop:enable Lint/DuplicateMethods
def validate
@errors = []
if to_addresses.empty? && cc_addresses.empty? && bcc_addresses.empty?
@errors << "NoRecipients"
end
if to_addresses.size > 50
@errors << "TooManyToAddresses"
end
if cc_addresses.size > 50
@errors << "TooManyCCAddresses"
end
if bcc_addresses.size > 50
@errors << "TooManyBCCAddresses"
end
if @plain_body.blank? && @html_body.blank?
@errors << "NoContent"
end
if from.blank?
@errors << "FromAddressMissing"
end
if domain.nil?
@errors << "UnauthenticatedFromAddress"
end
if attachments.present?
attachments.each do |attachment|
if attachment[:name].blank?
@errors << "AttachmentMissingName" unless @errors.include?("AttachmentMissingName")
elsif attachment[:data].blank?
@errors << "AttachmentMissingData" unless @errors.include?("AttachmentMissingData")
end
end
end
@errors
end
def raw_message
@raw_message ||= begin
mail = Mail.new
if @custom_headers.is_a?(Hash)
@custom_headers.each { |key, value| mail[key.to_s] = value.to_s }
end
mail.to = to_addresses.join(", ") if to_addresses.present?
mail.cc = cc_addresses.join(", ") if cc_addresses.present?
mail.from = @from
mail.sender = @sender
mail.subject = @subject
mail.reply_to = @reply_to
mail.part content_type: "multipart/alternative" do |p|
if @plain_body.present?
p.text_part = Mail::Part.new
p.text_part.body = @plain_body
end
if @html_body.present?
p.html_part = Mail::Part.new
p.html_part.content_type = "text/html; charset=UTF-8"
p.html_part.body = @html_body
end
end
attachments.each do |attachment|
mail.attachments[attachment[:name]] = {
mime_type: attachment[:content_type],
content: attachment[:data]
}
end
mail.header["Received"] = ReceivedHeader.generate(@server, @source_type, @ip, :http)
mail.message_id = "<#{@message_id}>"
mail.to_s
end
end
def create_message(address)
message = @server.message_db.new_message
message.scope = "outgoing"
message.rcpt_to = address
message.mail_from = from_address
message.domain_id = domain.id
message.raw_message = raw_message
message.tag = tag
message.credential_id = credential&.id
message.received_with_ssl = true
message.bounce = @bounce
message.save
{ id: message.id, token: message.token }
end
end
================================================
FILE: app/models/queued_message.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: queued_messages
#
# id :integer not null, primary key
# server_id :integer
# message_id :integer
# domain :string(255)
# locked_by :string(255)
# locked_at :datetime
# retry_after :datetime
# created_at :datetime
# updated_at :datetime
# ip_address_id :integer
# attempts :integer default(0)
# route_id :integer
# manual :boolean default(FALSE)
# batch_key :string(255)
#
# Indexes
#
# index_queued_messages_on_domain (domain)
# index_queued_messages_on_message_id (message_id)
# index_queued_messages_on_server_id (server_id)
#
class QueuedMessage < ApplicationRecord
include HasMessage
include HasLocking
belongs_to :server
belongs_to :ip_address, optional: true
before_create :allocate_ip_address
scope :ready_with_delayed_retry, -> { where("retry_after IS NULL OR retry_after < ?", 30.seconds.ago) }
scope :with_stale_lock, -> { where("locked_at IS NOT NULL AND locked_at < ?", Postal::Config.postal.queued_message_lock_stale_days.days.ago) }
def retry_now
update!(retry_after: nil)
end
def send_bounce
return unless message.send_bounces?
BounceMessage.new(server, message).queue
end
def allocate_ip_address
return unless Postal.ip_pools?
return if message.nil?
pool = server.ip_pool_for_message(message)
return if pool.nil?
self.ip_address = pool.ip_addresses.select_by_priority
end
def batchable_messages(limit = 10)
unless locked?
raise Postal::Error, "Must lock current message before locking any friends"
end
if batch_key.nil?
[]
else
time = Time.now
locker = Postal.locker_name
self.class.ready.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: nil, locked_at: nil).limit(limit).update_all(locked_by: locker, locked_at: time)
QueuedMessage.where(batch_key: batch_key, ip_address_id: ip_address_id, locked_by: locker, locked_at: time).where.not(id: id)
end
end
end
================================================
FILE: app/models/route.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: routes
#
# id :integer not null, primary key
# uuid :string(255)
# server_id :integer
# domain_id :integer
# endpoint_id :integer
# endpoint_type :string(255)
# name :string(255)
# spam_mode :string(255)
# created_at :datetime
# updated_at :datetime
# token :string(255)
# mode :string(255)
#
# Indexes
#
# index_routes_on_token (token)
#
class Route < ApplicationRecord
MODES = %w[Endpoint Accept Hold Bounce Reject].freeze
SPAM_MODES = %w[Mark Quarantine Fail].freeze
ENDPOINT_TYPES = %w[SMTPEndpoint HTTPEndpoint AddressEndpoint].freeze
include HasUUID
belongs_to :server
belongs_to :domain, optional: true
belongs_to :endpoint, polymorphic: true, optional: true
has_many :additional_route_endpoints, dependent: :destroy
validates :name, presence: true, format: /\A(([a-z0-9\-.]*)|(\*)|(__returnpath__))\z/
validates :spam_mode, inclusion: { in: SPAM_MODES }
validates :endpoint, presence: { if: proc { mode == "Endpoint" } }
validates :domain_id, presence: { unless: :return_path? }
validate :validate_route_is_routed
validate :validate_domain_belongs_to_server
validate :validate_endpoint_belongs_to_server
validate :validate_name_uniqueness
validate :validate_return_path_route_endpoints
validate :validate_no_additional_routes_on_non_endpoint_route
after_save :save_additional_route_endpoints
random_string :token, type: :chars, length: 8, unique: true
def return_path?
name == "__returnpath__"
end
def description
if return_path?
"Return Path"
else
"#{name}@#{domain.name}"
end
end
def _endpoint
if mode == "Endpoint"
@endpoint ||= endpoint ? "#{endpoint.class}##{endpoint.uuid}" : nil
else
@endpoint ||= mode
end
end
def _endpoint=(value)
if value.blank?
self.endpoint = nil
self.mode = nil
elsif value =~ /\#/
class_name, id = value.split("#", 2)
unless ENDPOINT_TYPES.include?(class_name)
raise Postal::Error, "Invalid endpoint class name '#{class_name}'"
end
self.endpoint = class_name.constantize.find_by_uuid(id)
self.mode = "Endpoint"
else
self.endpoint = nil
self.mode = value
end
end
def forward_address
@forward_address ||= "#{token}@#{Postal::Config.dns.route_domain}"
end
def wildcard?
name == "*"
end
def additional_route_endpoints_array
@additional_route_endpoints_array ||= additional_route_endpoints.map(&:_endpoint)
end
def additional_route_endpoints_array=(array)
@additional_route_endpoints_array = array.reject(&:blank?)
end
def save_additional_route_endpoints
return unless @additional_route_endpoints_array
seen = []
@additional_route_endpoints_array.each do |item|
if existing = additional_route_endpoints.find_by_endpoint(item)
seen << existing.id
else
route = additional_route_endpoints.build(_endpoint: item)
if route.save
seen << route.id
else
route.errors.each do |_, message|
errors.add :base, message
end
raise ActiveRecord::RecordInvalid
end
end
end
additional_route_endpoints.where.not(id: seen).destroy_all
end
#
# This message will create a suitable number of message objects for messages that
# are destined for this route. It receives a block which can set the message content
# but most information is specified already.
#
# Returns an array of created messages.
#
def create_messages(&block)
messages = []
message = build_message
if mode == "Endpoint" && server.message_db.schema_version >= 18
message.endpoint_type = endpoint_type
message.endpoint_id = endpoint_id
end
block.call(message)
message.save
messages << message
# Also create any messages for additional endpoints that might exist
if mode == "Endpoint" && server.message_db.schema_version >= 18
additional_route_endpoints.each do |endpoint|
next unless endpoint.endpoint
message = build_message
message.endpoint_id = endpoint.endpoint_id
message.endpoint_type = endpoint.endpoint_type
block.call(message)
message.save
messages << message
end
end
messages
end
def build_message
message = server.message_db.new_message
message.scope = "incoming"
message.rcpt_to = description
message.domain_id = domain&.id
message.route_id = id
message
end
private
def validate_route_is_routed
return unless mode.nil?
errors.add :endpoint, "must be chosen"
end
def validate_domain_belongs_to_server
if domain && ![server, server.organization].include?(domain.owner)
errors.add :domain, :invalid
end
return unless domain && !domain.verified?
errors.add :domain, "has not been verified yet"
end
def validate_endpoint_belongs_to_server
return unless endpoint && endpoint&.server != server
errors.add :endpoint, :invalid
end
def validate_name_uniqueness
return if server.nil?
if domain
if route = Route.includes(:domain).where(domains: { name: domain.name }, name: name).where.not(id: id).first
errors.add :name, "is configured on the #{route.server.full_permalink} mail server"
end
elsif Route.where(name: "__returnpath__").where.not(id: id).exists?
errors.add :base, "A return path route already exists for this server"
end
end
def validate_return_path_route_endpoints
return unless return_path?
return unless mode != "Endpoint" || endpoint_type != "HTTPEndpoint"
errors.add :base, "Return path routes must point to an HTTP endpoint"
end
def validate_no_additional_routes_on_non_endpoint_route
return unless mode != "Endpoint" && !additional_route_endpoints_array.empty?
errors.add :base, "Additional routes are not permitted unless the primary route is an actual endpoint"
end
class << self
def find_by_name_and_domain(name, domain)
route = Route.includes(:domain).where(name: name, domains: { name: domain }).first
if route.nil?
route = Route.includes(:domain).where(name: "*", domains: { name: domain }).first
end
route
end
end
end
================================================
FILE: app/models/scheduled_task.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: scheduled_tasks
#
# id :bigint not null, primary key
# name :string(255)
# next_run_after :datetime
#
# Indexes
#
# index_scheduled_tasks_on_name (name) UNIQUE
#
class ScheduledTask < ApplicationRecord
end
================================================
FILE: app/models/server.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: servers
#
# id :integer not null, primary key
# allow_sender :boolean default(FALSE)
# deleted_at :datetime
# domains_not_to_click_track :text(65535)
# log_smtp_data :boolean default(FALSE)
# message_retention_days :integer
# mode :string(255)
# name :string(255)
# outbound_spam_threshold :decimal(8, 2)
# permalink :string(255)
# postmaster_address :string(255)
# privacy_mode :boolean default(FALSE)
# raw_message_retention_days :integer
# raw_message_retention_size :integer
# send_limit :integer
# send_limit_approaching_at :datetime
# send_limit_approaching_notified_at :datetime
# send_limit_exceeded_at :datetime
# send_limit_exceeded_notified_at :datetime
# spam_failure_threshold :decimal(8, 2)
# spam_threshold :decimal(8, 2)
# suspended_at :datetime
# suspension_reason :string(255)
# token :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
# ip_pool_id :integer
# organization_id :integer
#
# Indexes
#
# index_servers_on_organization_id (organization_id)
# index_servers_on_permalink (permalink)
# index_servers_on_token (token)
# index_servers_on_uuid (uuid)
#
class Server < ApplicationRecord
RESERVED_PERMALINKS = %w[new all search stats edit manage delete destroy remove].freeze
MODES = %w[Live Development].freeze
include HasUUID
include HasSoftDestroy
attr_accessor :provision_database
belongs_to :organization
belongs_to :ip_pool, optional: true
has_many :domains, dependent: :destroy, as: :owner
has_many :credentials, dependent: :destroy
has_many :smtp_endpoints, dependent: :destroy
has_many :http_endpoints, dependent: :destroy
has_many :address_endpoints, dependent: :destroy
has_many :routes, dependent: :destroy
has_many :queued_messages, dependent: :delete_all
has_many :webhooks, dependent: :destroy
has_many :webhook_requests, dependent: :destroy
has_many :track_domains, dependent: :destroy
has_many :ip_pool_rules, dependent: :destroy, as: :owner
random_string :token, type: :chars, length: 6, unique: true, upper_letters_only: true
default_value :permalink, -> { name ? name.parameterize : nil }
default_value :raw_message_retention_days, -> { 30 }
default_value :raw_message_retention_size, -> { 2048 }
default_value :message_retention_days, -> { 60 }
default_value :spam_threshold, -> { Postal::Config.postal.default_spam_threshold }
default_value :spam_failure_threshold, -> { Postal::Config.postal.default_spam_failure_threshold }
validates :name, presence: true, uniqueness: { scope: :organization_id, case_sensitive: false }
validates :mode, inclusion: { in: MODES }
validates :permalink, presence: true, uniqueness: { scope: :organization_id, case_sensitive: false }, format: { with: /\A[a-z0-9-]*\z/ }, exclusion: { in: RESERVED_PERMALINKS }
validate :validate_ip_pool_belongs_to_organization
before_validation(on: :create) do
self.token = token.downcase if token
end
after_create do
unless provision_database == false
message_db.provisioner.provision
end
end
after_commit(on: :destroy) do
unless provision_database == false
message_db.provisioner.drop
end
end
def status
if suspended?
"Suspended"
else
mode
end
end
def full_permalink
"#{organization.permalink}/#{permalink}"
end
def suspended?
suspended_at.present? || organization.suspended?
end
def actual_suspension_reason
return unless suspended?
if suspended_at.nil?
organization.suspension_reason
else
suspension_reason
end
end
def to_param
permalink
end
def message_db
@message_db ||= Postal::MessageDB::Database.new(organization_id, id)
end
delegate :message, to: :message_db
def message_rate
@message_rate ||= message_db.live_stats.total(60, types: [:incoming, :outgoing]) / 60.0
end
def held_messages
@held_messages ||= message_db.messages(where: { held: true }, count: true)
end
def throughput_stats
@throughput_stats ||= begin
incoming = message_db.live_stats.total(60, types: [:incoming])
outgoing = message_db.live_stats.total(60, types: [:outgoing])
outgoing_usage = send_limit ? (outgoing / send_limit.to_f) * 100 : 0
{
incoming: incoming,
outgoing: outgoing,
outgoing_usage: outgoing_usage
}
end
end
def bounce_rate
@bounce_rate ||= begin
time = Time.now.utc
total_outgoing = 0.0
total_bounces = 0.0
message_db.statistics.get(:daily, [:outgoing, :bounces], time, 30).each do |_, stat|
total_outgoing += stat[:outgoing]
total_bounces += stat[:bounces]
end
total_outgoing.zero? ? 0 : (total_bounces / total_outgoing) * 100
end
end
def domain_stats
domains = Domain.where(owner_id: id, owner_type: "Server").to_a
total = 0
unverified = 0
bad_dns = 0
domains.each do |domain|
total += 1
unverified += 1 unless domain.verified?
bad_dns += 1 if domain.verified? && !domain.dns_ok?
end
[total, unverified, bad_dns]
end
def webhook_hash
{
uuid: uuid,
name: name,
permalink: permalink,
organization: organization&.permalink
}
end
def send_volume
@send_volume ||= message_db.live_stats.total(60, types: [:outgoing])
end
def send_limit_approaching?
return false unless send_limit
(send_volume >= send_limit * 0.90)
end
def send_limit_exceeded?
return false unless send_limit
send_volume >= send_limit
end
def send_limit_warning(type)
if organization.notification_addresses.present?
AppMailer.send("server_send_limit_#{type}", self).deliver
end
update_column("send_limit_#{type}_notified_at", Time.now)
WebhookRequest.trigger(self, "SendLimit#{type.to_s.capitalize}", server: webhook_hash, volume: send_volume, limit: send_limit)
end
def queue_size
@queue_size ||= queued_messages.ready.count
end
# Return the domain which can be used to authenticate emails sent from the given e-mail address.
#
# @param address [String] an e-mail address
# @return [Domain, nil] the domain to use for authentication
def authenticated_domain_for_address(address)
return nil if address.blank?
address = Postal::Helpers.strip_name_from_address(address)
uname, domain_name = address.split("@", 2)
return nil unless uname
return nil unless domain_name
# Find a verified domain which directly matches the domain name for the given address.
domain = Domain.verified
.order(owner_type: :desc)
.where("(owner_type = 'Organization' AND owner_id = ?) OR " \
"(owner_type = 'Server' AND owner_id = ?)", organization_id, id)
.where(name: domain_name)
.first
# If there is a matching domain, return it
return domain if domain
# Otherwise, we need to look to see if there is a domain configured which can be used as the authenticated
# domain for any domain. This will look for domains directly within the server and return that.
any_domain = domains.verified.where(use_for_any: true).order(:name).first
return any_domain if any_domain
# Return nil if we can't find anything suitable
nil
end
def find_authenticated_domain_from_headers(headers)
header_to_check = ["from"]
header_to_check << "sender" if allow_sender?
header_to_check.each do |header_name|
if headers[header_name].is_a?(Array)
values = headers[header_name]
else
values = [headers[header_name].to_s]
end
authenticated_domains = values.map { |v| authenticated_domain_for_address(v) }.compact
if authenticated_domains.size == values.size
return authenticated_domains.first
end
end
nil
end
def suspend(reason)
self.suspended_at = Time.now
self.suspension_reason = reason
save!
if organization.notification_addresses.present?
AppMailer.server_suspended(self).deliver
end
true
end
def unsuspend
self.suspended_at = nil
self.suspension_reason = nil
save!
end
def ip_pool_for_message(message)
return unless message.scope == "outgoing"
[self, organization].each do |scope|
rules = scope.ip_pool_rules.order(created_at: :desc)
rules.each do |rule|
if rule.apply_to_message?(message)
return rule.ip_pool
end
end
end
ip_pool
end
private
def validate_ip_pool_belongs_to_organization
return unless ip_pool && ip_pool_id_changed? && !organization.ip_pools.include?(ip_pool)
errors.add :ip_pool_id, "must belong to the organization"
end
class << self
def triggered_send_limit(type)
servers = where("send_limit_#{type}_at IS NOT NULL AND send_limit_#{type}_at > ?", 3.minutes.ago)
servers.where("send_limit_#{type}_notified_at IS NULL OR send_limit_#{type}_notified_at < ?", 1.hour.ago)
end
def send_send_limit_notifications
[:approaching, :exceeded].each_with_object({}) do |type, hash|
hash[type] = 0
servers = triggered_send_limit(type)
next if servers.empty?
servers.each do |server|
hash[type] += 1
server.send_limit_warning(type)
end
end
end
def [](id, extra = nil)
if id.is_a?(String) && id =~ /\A(\w+)\/(\w+)\z/
joins(:organization).where(
organizations: { permalink: ::Regexp.last_match(1) }, permalink: ::Regexp.last_match(2)
).first
else
find_by(id: id.to_i)
end
end
end
end
================================================
FILE: app/models/smtp_endpoint.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: smtp_endpoints
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# hostname :string(255)
# ssl_mode :string(255)
# port :integer
# error :text(65535)
# disabled_until :datetime
# last_used_at :datetime
# created_at :datetime
# updated_at :datetime
#
class SMTPEndpoint < ApplicationRecord
include HasUUID
belongs_to :server
has_many :routes, as: :endpoint
has_many :additional_route_endpoints, dependent: :destroy, as: :endpoint
SSL_MODES = %w[None Auto STARTTLS TLS].freeze
before_destroy :update_routes
validates :name, presence: true
validates :hostname, presence: true, format: /\A[a-z0-9.-]*\z/
validates :ssl_mode, inclusion: { in: SSL_MODES }
validates :port, numericality: { only_integer: true, allow_blank: true }
def description
"#{name} (#{hostname})"
end
def mark_as_used
update_column(:last_used_at, Time.now)
end
def update_routes
routes.each { |r| r.update(endpoint: nil, mode: "Reject") }
end
def to_smtp_client_server
SMTPClient::Server.new(hostname, port: port || 25, ssl_mode: ssl_mode)
end
end
================================================
FILE: app/models/statistic.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: statistics
#
# id :integer not null, primary key
# total_incoming :bigint default(0)
# total_messages :bigint default(0)
# total_outgoing :bigint default(0)
#
class Statistic < ApplicationRecord
def self.global
Statistic.first || Statistic.create
end
end
================================================
FILE: app/models/track_domain.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: track_domains
#
# id :integer not null, primary key
# uuid :string(255)
# server_id :integer
# domain_id :integer
# name :string(255)
# dns_checked_at :datetime
# dns_status :string(255)
# dns_error :string(255)
# created_at :datetime not null
# updated_at :datetime not null
# ssl_enabled :boolean default(TRUE)
# track_clicks :boolean default(TRUE)
# track_loads :boolean default(TRUE)
# excluded_click_domains :text(65535)
#
require "resolv"
class TrackDomain < ApplicationRecord
include HasUUID
belongs_to :server
belongs_to :domain
validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/ }, uniqueness: { scope: :domain_id, case_sensitive: false, message: "is already added" }
validates :domain_id, uniqueness: { scope: :server_id, case_sensitive: false, message: "already has a track domain for this server" }
validate :validate_domain_belongs_to_server
scope :ok, -> { where(dns_status: "OK") }
after_create :check_dns, unless: :dns_status
before_validation do
self.server = domain.server if domain && server.nil?
end
def full_name
"#{name}.#{domain.name}"
end
def excluded_click_domains_array
@excluded_click_domains_array ||= excluded_click_domains ? excluded_click_domains.split("\n").map(&:strip) : []
end
def dns_ok?
dns_status == "OK"
end
def check_dns
records = domain.resolver.cname(full_name)
if records.empty?
self.dns_status = "Missing"
self.dns_error = "There is no record at #{full_name}"
elsif records.size == 1 && records.first == Postal::Config.dns.track_domain
self.dns_status = "OK"
self.dns_error = nil
else
self.dns_status = "Invalid"
self.dns_error = "There is a CNAME record at #{full_name} but it points to #{records.first} which is incorrect. It should point to #{Postal::Config.dns.track_domain}."
end
self.dns_checked_at = Time.now
save!
dns_ok?
end
def use_ssl?
ssl_enabled?
end
def validate_domain_belongs_to_server
return unless domain && ![server, server.organization].include?(domain.owner)
errors.add :domain, "does not belong to the server or the server's organization"
end
end
================================================
FILE: app/models/user.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# admin :boolean default(FALSE)
# email_address :string(255)
# email_verification_token :string(255)
# email_verified_at :datetime
# first_name :string(255)
# last_name :string(255)
# oidc_issuer :string(255)
# oidc_uid :string(255)
# password_digest :string(255)
# password_reset_token :string(255)
# password_reset_token_valid_until :datetime
# time_zone :string(255)
# uuid :string(255)
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
# index_users_on_email_address (email_address)
# index_users_on_uuid (uuid)
#
class User < ApplicationRecord
include HasUUID
include HasAuthentication
validates :first_name, presence: true
validates :last_name, presence: true
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }
default_value :time_zone, -> { "UTC" }
has_many :organization_users, dependent: :destroy, as: :user
has_many :organizations, through: :organization_users
def organizations_scope
if admin?
@organizations_scope ||= Organization.present
else
@organizations_scope ||= organizations.present
end
end
def name
"#{first_name} #{last_name}"
end
def password?
password_digest.present?
end
def oidc?
oidc_uid.present?
end
def to_param
uuid
end
def email_tag
"#{name} <#{email_address}>"
end
class << self
# Lookup a user by email address
#
# @param email [String] the email address
#
# @return [User, nil] the user
def [](email)
find_by(email_address: email)
end
# Find a user based on an OIDC authentication hash
#
# @param auth [Hash] the authentication hash
# @param logger [Logger] a logger to log debug information to
#
# @return [User, nil] the user
def find_from_oidc(auth, logger: nil)
config = Postal::Config.oidc
uid = auth[config.uid_field]
oidc_name = auth[config.name_field]
oidc_email_address = auth[config.email_address_field]
logger&.debug "got auth details from issuer: #{auth.inspect}"
# look for an existing user with the same UID and OIDC issuer. If we find one,
# this is the user we'll want to use.
user = where(oidc_uid: uid, oidc_issuer: config.issuer).first
if user
logger&.debug "found user with UID #{uid} for issuer #{config.issuer} (user ID: #{user.id})"
else
logger&.debug "no user with UID #{uid} for issuer #{config.issuer}"
end
# if we don't have an existing user, we will look for users which have no OIDC
# credentials but with a matching e-mail address.
if user.nil? && oidc_email_address.present?
user = where(oidc_uid: nil, email_address: oidc_email_address).first
if user
logger&.debug "found user with e-mail address #{oidc_email_address} (user ID: #{user.id})"
else
logger&.debug "no user with e-mail address #{oidc_email_address}"
end
end
# now, if we still don't have a user, we're not going to create one so we'll just
# return nil (we might auto create users in the future but not right now)
return if user.nil?
# otherwise, let's update our user as appropriate
user.oidc_uid = uid
user.oidc_issuer = config.issuer
user.email_address = oidc_email_address if oidc_email_address.present?
user.first_name, user.last_name = oidc_name.split(/\s+/, 2) if oidc_name.present?
user.password = nil
user.save!
# return the user
user
end
end
end
================================================
FILE: app/models/user_invite.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: user_invites
#
# id :integer not null, primary key
# uuid :string(255)
# email_address :string(255)
# expires_at :datetime
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
# index_user_invites_on_uuid (uuid)
#
class UserInvite < ApplicationRecord
include HasUUID
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: /@/, allow_blank: true }
has_many :organization_users, dependent: :destroy, as: :user
has_many :organizations, through: :organization_users
default_value :expires_at, -> { 7.days.from_now }
scope :active, -> { where("expires_at > ?", Time.now) }
def md5_for_gravatar
@md5_for_gravatar ||= Digest::MD5.hexdigest(email_address.to_s.downcase)
end
def avatar_url
@avatar_url ||= email_address ? "https://secure.gravatar.com/avatar/#{md5_for_gravatar}?rating=PG&size=120&d=mm" : nil
end
def name
email_address
end
def accept(user)
transaction do
organization_users.each do |ou|
ou.update(user: user) || ou.destroy
end
organization_users.reload
destroy
end
end
def reject
destroy
end
end
================================================
FILE: app/models/webhook.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhooks
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# url :string(255)
# last_used_at :datetime
# all_events :boolean default(FALSE)
# enabled :boolean default(TRUE)
# sign :boolean default(TRUE)
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
# index_webhooks_on_server_id (server_id)
#
class Webhook < ApplicationRecord
include HasUUID
belongs_to :server
has_many :webhook_events, dependent: :destroy
has_many :webhook_requests
validates :name, presence: true
validates :url, presence: true, format: { with: /\Ahttps?:\/\/[a-z0-9\-._?=&\/+:%@]+\z/i, allow_blank: true }
scope :enabled, -> { where(enabled: true) }
after_save :save_events
after_save :destroy_events_when_all_events_enabled
def events
@events ||= webhook_events.map(&:event)
end
def events=(value)
@events = value.map(&:to_s).select(&:present?)
end
private
def save_events
return unless @events
@events.each do |event|
webhook_events.where(event: event).first_or_create!
end
webhook_events.where.not(event: @events).destroy_all
end
def destroy_events_when_all_events_enabled
return unless all_events
webhook_events.destroy_all
end
end
================================================
FILE: app/models/webhook_event.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhook_events
#
# id :integer not null, primary key
# webhook_id :integer
# event :string(255)
# created_at :datetime
#
# Indexes
#
# index_webhook_events_on_webhook_id (webhook_id)
#
class WebhookEvent < ApplicationRecord
EVENTS = %w[
MessageSent
MessageDelayed
MessageDeliveryFailed
MessageHeld
MessageBounced
MessageLinkClicked
MessageLoaded
DomainDNSError
].freeze
belongs_to :webhook
validates :event, presence: true
end
================================================
FILE: app/models/webhook_request.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhook_requests
#
# id :integer not null, primary key
# attempts :integer default(0)
# error :text(65535)
# event :string(255)
# locked_at :datetime
# locked_by :string(255)
# payload :text(65535)
# retry_after :datetime
# url :string(255)
# uuid :string(255)
# created_at :datetime
# server_id :integer
# webhook_id :integer
#
# Indexes
#
# index_webhook_requests_on_locked_by (locked_by)
#
class WebhookRequest < ApplicationRecord
include HasUUID
include HasLocking
belongs_to :server
belongs_to :webhook, optional: true
validates :url, presence: true
validates :event, presence: true
serialize :payload, type: Hash
class << self
def trigger(server, event, payload = {})
unless server.is_a?(Server)
server = Server.find(server.to_i)
end
webhooks = server.webhooks.enabled.includes(:webhook_events).references(:webhook_events).where("webhooks.all_events = ? OR webhook_events.event = ?", true, event)
webhooks.each do |webhook|
server.webhook_requests.create!(event: event, payload: payload, webhook: webhook, url: webhook.url)
end
end
end
end
================================================
FILE: app/models/worker_role.rb
================================================
# frozen_string_literal: true
# == Schema Information
#
# Table name: worker_roles
#
# id :bigint not null, primary key
# acquired_at :datetime
# role :string(255)
# worker :string(255)
#
# Indexes
#
# index_worker_roles_on_role (role) UNIQUE
#
class WorkerRole < ApplicationRecord
class << self
# Acquire or renew a lock for the given role.
#
# @param role [String] The name of the role to acquire
# @return [Symbol, false] True if the lock was acquired or renewed, false otherwise
def acquire(role)
# update our existing lock if we already have one
updates = where(role: role, worker: Postal.locker_name).update_all(acquired_at: Time.current)
return :renewed if updates.positive?
# attempt to steal a role from another worker
updates = where(role: role).where("acquired_at is null OR acquired_at < ?", 5.minutes.ago)
.update_all(acquired_at: Time.current, worker: Postal.locker_name)
return :stolen if updates.positive?
# attempt to create a new role for this worker
begin
create!(role: role, worker: Postal.locker_name, acquired_at: Time.current)
:created
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
false
end
end
# Release a lock for the given role for the current process.
#
# @param role [String] The name of the role to release
# @return [Boolean] True if the lock was released, false otherwise
def release(role)
updates = where(role: role, worker: Postal.locker_name).delete_all
updates.positive?
end
end
end
================================================
FILE: app/scheduled_tasks/action_deletions_scheduled_task.rb
================================================
# frozen_string_literal: true
class ActionDeletionsScheduledTask < ApplicationScheduledTask
def call
Organization.deleted.each do |org|
logger.info "permanently removing organization #{org.id} (#{org.permalink})"
org.destroy
end
Server.deleted.each do |server|
logger.info "permanently removing server #{server.id} (#{server.full_permalink})"
server.destroy
end
end
end
================================================
FILE: app/scheduled_tasks/application_scheduled_task.rb
================================================
# frozen_string_literal: true
class ApplicationScheduledTask
def initialize(logger:)
@logger = logger
end
def call
raise NotImplementedError
end
attr_reader :logger
class << self
def next_run_after
quarter_past_each_hour
end
private
def quarter_past_each_hour
time = Time.current
time = time.change(min: 15, sec: 0)
time += 1.hour if time < Time.current
time
end
def quarter_to_each_hour
time = Time.current
time = time.change(min: 45, sec: 0)
time += 1.hour if time < Time.current
time
end
def three_am
time = Time.current
time = time.change(hour: 3, min: 0, sec: 0)
time += 1.day if time < Time.current
time
end
end
end
================================================
FILE: app/scheduled_tasks/check_all_dns_scheduled_task.rb
================================================
# frozen_string_literal: true
class CheckAllDNSScheduledTask < ApplicationScheduledTask
def call
Domain.where.not(dns_checked_at: nil).where("dns_checked_at <= ?", 1.hour.ago).each do |domain|
logger.info "checking DNS for domain: #{domain.name}"
domain.check_dns(:auto)
end
TrackDomain.where("dns_checked_at IS NULL OR dns_checked_at <= ?", 1.hour.ago).includes(:domain).each do |domain|
logger.info "checking DNS for track domain: #{domain.full_name}"
domain.check_dns
end
end
end
================================================
FILE: app/scheduled_tasks/cleanup_authie_sessions_scheduled_task.rb
================================================
# frozen_string_literal: true
require "authie/session"
class CleanupAuthieSessionsScheduledTask < ApplicationScheduledTask
def call
Authie::Session.cleanup
end
end
================================================
FILE: app/scheduled_tasks/expire_held_messages_scheduled_task.rb
================================================
# frozen_string_literal: true
class ExpireHeldMessagesScheduledTask < ApplicationScheduledTask
def call
Server.all.each do |server|
messages = server.message_db.messages(where: {
status: "Held",
hold_expiry: { less_than: Time.now.to_f }
})
messages.each(&:cancel_hold)
end
end
end
================================================
FILE: app/scheduled_tasks/process_message_retention_scheduled_task.rb
================================================
# frozen_string_literal: true
class ProcessMessageRetentionScheduledTask < ApplicationScheduledTask
def call
Server.all.each do |server|
if server.raw_message_retention_days
# If the server has a maximum number of retained raw messages, remove any that are older than this
logger.info "Tidying raw messages (by days) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_days} days."
server.message_db.provisioner.remove_raw_tables_older_than(server.raw_message_retention_days)
end
if server.raw_message_retention_size
logger.info "Tidying raw messages (by size) for #{server.permalink} (ID: #{server.id}). Keeping #{server.raw_message_retention_size} MB of data."
server.message_db.provisioner.remove_raw_tables_until_less_than_size(server.raw_message_retention_size * 1024 * 1024)
end
if server.message_retention_days
logger.info "Tidying messages for #{server.permalink} (ID: #{server.id}). Keeping #{server.message_retention_days} days."
server.message_db.provisioner.remove_messages(server.message_retention_days)
end
end
end
def self.next_run_after
three_am
end
end
================================================
FILE: app/scheduled_tasks/prune_suppression_lists_scheduled_task.rb
================================================
# frozen_string_literal: true
class PruneSuppressionListsScheduledTask < ApplicationScheduledTask
def call
Server.all.each do |s|
logger.info "Pruning suppression lists for server #{s.id}"
s.message_db.suppression_list.prune
end
end
def self.next_run_after
three_am
end
end
================================================
FILE: app/scheduled_tasks/prune_webhook_requests_scheduled_task.rb
================================================
# frozen_string_literal: true
class PruneWebhookRequestsScheduledTask < ApplicationScheduledTask
def call
Server.all.each do |s|
logger.info "Pruning webhook requests for server #{s.id}"
s.message_db.webhooks.prune
end
end
def self.next_run_after
quarter_to_each_hour
end
end
================================================
FILE: app/scheduled_tasks/send_notifications_scheduled_task.rb
================================================
# frozen_string_literal: true
class SendNotificationsScheduledTask < ApplicationScheduledTask
def call
Server.send_send_limit_notifications
end
def self.next_run_after
1.minute.from_now
end
end
================================================
FILE: app/scheduled_tasks/tidy_queued_messages_task.rb
================================================
# frozen_string_literal: true
class TidyQueuedMessagesTask < ApplicationScheduledTask
def call
QueuedMessage.with_stale_lock.in_batches do |messages|
messages.each do |message|
logger.info "removing queued message #{message.id} (locked at #{message.locked_at} by #{message.locked_by})"
message.destroy
end
end
end
def self.next_run_after
quarter_to_each_hour
end
end
================================================
FILE: app/senders/base_sender.rb
================================================
# frozen_string_literal: true
class BaseSender
def start
end
def send_message(message)
end
def finish
end
end
================================================
FILE: app/senders/http_sender.rb
================================================
# frozen_string_literal: true
class HTTPSender < BaseSender
def initialize(endpoint, options = {})
super()
@endpoint = endpoint
@options = options
@log_id = SecureRandom.alphanumeric(8).upcase
end
def send_message(message)
start_time = Time.now
result = SendResult.new
result.log_id = @log_id
request_options = {}
request_options[:sign] = true
request_options[:timeout] = @endpoint.timeout || 5
case @endpoint.encoding
when "BodyAsJSON"
request_options[:json] = parameters(message, flat: false).to_json
when "FormData"
request_options[:params] = parameters(message, flat: true)
end
log "Sending request to #{@endpoint.url}"
response = Postal::HTTP.post(@endpoint.url, request_options)
result.secure = !!response[:secure] # rubocop:disable Style/DoubleNegation
result.details = "Received a #{response[:code]} from #{@endpoint.url}"
log " -> Received: #{response[:code]}"
if response[:body]
log " -> Body: #{response[:body][0, 255]}"
result.output = response[:body].to_s[0, 500].strip
end
if response[:code] >= 200 && response[:code] < 300
# This is considered a success
result.type = "Sent"
elsif response[:code] >= 500 && response[:code] < 600
# This is temporary. They might fix their server so it should soft fail.
result.type = "SoftFail"
result.retry = true
elsif response[:code].negative?
# Connection/SSL etc... errors
result.type = "SoftFail"
result.retry = true
result.connect_error = true
elsif response[:code] == 429
# Rate limit exceeded, treat as a hard fail and don't send bounces
result.type = "HardFail"
result.suppress_bounce = true
else
# This is permanent. Any other error isn't cool with us.
result.type = "HardFail"
end
result.time = (Time.now - start_time).to_f.round(2)
result
end
private
def log(text)
Postal.logger.info text, id: @log_id, component: "http-sender"
end
def parameters(message, options = {})
case @endpoint.format
when "Hash"
hash = {
id: message.id,
rcpt_to: message.rcpt_to,
mail_from: message.mail_from,
token: message.token,
subject: message.subject,
message_id: message.message_id,
timestamp: message.timestamp.to_f,
size: message.size,
spam_status: message.spam_status,
bounce: message.bounce,
received_with_ssl: message.received_with_ssl,
to: message.headers["to"]&.last,
cc: message.headers["cc"]&.last,
from: message.headers["from"]&.last,
date: message.headers["date"]&.last,
in_reply_to: message.headers["in-reply-to"]&.last,
references: message.headers["references"]&.last,
html_body: message.html_body,
attachment_quantity: message.attachments.size,
auto_submitted: message.headers["auto-submitted"]&.last,
reply_to: message.headers["reply-to"]
}
if @endpoint.strip_replies
hash[:plain_body], hash[:replies_from_plain_body] = ReplySeparator.separate(message.plain_body)
else
hash[:plain_body] = message.plain_body
end
if @endpoint.include_attachments?
if options[:flat]
message.attachments.each_with_index do |a, i|
hash["attachments[#{i}][filename]"] = a.filename
hash["attachments[#{i}][content_type]"] = a.content_type
hash["attachments[#{i}][size]"] = a.body.to_s.bytesize.to_s
hash["attachments[#{i}][data]"] = Base64.encode64(a.body.to_s)
end
else
hash[:attachments] = message.attachments.map do |a|
{
filename: a.filename,
content_type: a.mime_type,
size: a.body.to_s.bytesize,
data: Base64.encode64(a.body.to_s)
}
end
end
end
hash
when "RawMessage"
{
id: message.id,
rcpt_to: message.rcpt_to,
mail_from: message.mail_from,
message: Base64.encode64(message.raw_message),
base64: true,
size: message.size.to_i
}
else
{}
end
end
end
================================================
FILE: app/senders/send_result.rb
================================================
# frozen_string_literal: true
class SendResult
attr_accessor :type
attr_accessor :details
attr_accessor :retry
attr_accessor :output
attr_accessor :secure
attr_accessor :connect_error
attr_accessor :log_id
attr_accessor :time
attr_accessor :suppress_bounce
def initialize
@details = ""
yield self if block_given?
end
end
================================================
FILE: app/senders/smtp_sender.rb
================================================
# frozen_string_literal: true
class SMTPSender < BaseSender
attr_reader :endpoints
# @param domain [String] the domain to send mesages to
# @param source_ip_address [IPAddress] the IP address to send messages from
# @param log_id [String] an ID to use when logging requests
def initialize(domain, source_ip_address = nil, servers: nil, log_id: nil, rcpt_to: nil)
super()
@domain = domain
@source_ip_address = source_ip_address
@rcpt_to = rcpt_to
# An array of servers to forcefully send the message to
@servers = servers
# Stores all connection errors which we have seen during this send sesssion.
@connection_errors = []
# Stores all endpoints that we have attempted to deliver mail to
@endpoints = []
# Generate a log ID which can be used if none has been provided to trace
# this SMTP session.
@log_id = log_id || SecureRandom.alphanumeric(8).upcase
end
def start
servers = @servers || self.class.smtp_relays || resolve_mx_records_for_domain || []
servers.each do |server|
server.endpoints.each do |endpoint|
result = connect_to_endpoint(endpoint)
return endpoint if result
end
end
false
end
def send_message(message)
# If we don't have a current endpoint than we should raise an error.
if @current_endpoint.nil?
return create_result("SoftFail") do |r|
r.retry = true
r.details = "No SMTP servers were available for #{@domain}."
if @endpoints.empty?
r.details += " No hosts to try."
else
hostnames = @endpoints.map { |e| e.server.hostname }.uniq
r.details += " Tried #{hostnames.to_sentence}."
end
r.output = @connection_errors.join(", ")
r.connect_error = true
end
end
mail_from = determine_mail_from_for_message(message)
raw_message = message.raw_message
# Append the Resent-Sender header to the mesage to include the
# MAIL FROM if the installation is configured to use that?
if Postal::Config.postal.use_resent_sender_header?
raw_message = "Resent-Sender: #{mail_from}\r\n" + raw_message
end
rcpt_to = determine_rcpt_to_for_message(message)
logger.info "Sending message #{message.server.id}::#{message.id} to #{rcpt_to}"
send_message_to_smtp_client(raw_message, mail_from, rcpt_to)
end
def finish
@endpoints.each(&:finish_smtp_session)
end
private
# Take a message and attempt to send it to the SMTP server that we are
# currently connected to. If there is a connection error, we will just
# reset the client and retry again once.
#
# @param raw_message [String] the raw message to send
# @param mail_from [String] the MAIL FROM address to use
# @param rcpt_to [String] the RCPT TO address to use
# @param retry_on_connection_error [Boolean] if true, we will retry the connection if there is an error
#
# @return [SendResult]
def send_message_to_smtp_client(raw_message, mail_from, rcpt_to, retry_on_connection_error: true)
start_time = Time.now
smtp_result = @current_endpoint.send_message(raw_message, mail_from, [rcpt_to])
logger.info "Accepted by #{@current_endpoint} for #{rcpt_to}"
create_result("Sent", start_time) do |r|
r.details = "Message for #{rcpt_to} accepted by #{@current_endpoint}"
r.details += " (from #{@current_endpoint.smtp_client.source_address})" if @current_endpoint.smtp_client.source_address
r.output = smtp_result.string
end
rescue Net::SMTPServerBusy, Net::SMTPAuthenticationError, Net::SMTPSyntaxError, Net::SMTPUnknownError, Net::ReadTimeout => e
logger.error "#{e.class}: #{e.message}"
@current_endpoint.reset_smtp_session
create_result("SoftFail", start_time) do |r|
r.details = "Temporary SMTP delivery error when sending to #{@current_endpoint}"
r.output = e.message
if e.message =~ /(\d+) seconds/
r.retry = ::Regexp.last_match(1).to_i + 10
elsif e.message =~ /(\d+) minutes/
r.retry = (::Regexp.last_match(1).to_i * 60) + 10
else
r.retry = true
end
end
rescue Net::SMTPFatalError => e
logger.error "#{e.class}: #{e.message}"
@current_endpoint.reset_smtp_session
create_result("HardFail", start_time) do |r|
r.details = "Permanent SMTP delivery error when sending to #{@current_endpoint}"
r.output = e.message
end
rescue StandardError => e
logger.error "#{e.class}: #{e.message}"
@current_endpoint.reset_smtp_session
if defined?(Sentry)
# Sentry.capture_exception(e, extra: { log_id: @log_id, server_id: message.server.id, message_id: message.id })
end
create_result("SoftFail", start_time) do |r|
r.type = "SoftFail"
r.retry = true
r.details = "An error occurred while sending the message to #{@current_endpoint}"
r.output = e.message
end
end
# Return the MAIL FROM which should be used for the given message
#
# @param message [MessageDB::Message]
# @return [String]
def determine_mail_from_for_message(message)
return "" if message.bounce
# If the domain has a valid custom return path configured, return
# that.
if message.domain.return_path_status == "OK"
return "#{message.server.token}@#{message.domain.return_path_domain}"
end
"#{message.server.token}@#{Postal::Config.dns.return_path_domain}"
end
# Return the RCPT TO to use for the given message in this sending session
#
# @param message [MessageDB::Message]
# @return [String]
def determine_rcpt_to_for_message(message)
return @rcpt_to if @rcpt_to
message.rcpt_to
end
# Return an array of server hostnames which should receive this message
#
# @return [Array]
def resolve_mx_records_for_domain
hostnames = DNSResolver.local.mx(@domain, raise_timeout_errors: true).map(&:last)
return [SMTPClient::Server.new(@domain)] if hostnames.empty?
hostnames.map { |hostname| SMTPClient::Server.new(hostname) }
end
# Attempt to begin an SMTP sesssion for the given endpoint. If successful, this endpoint
# becomes the current endpoints for the SMTP sender.
#
# Returns true if the session was established.
# Returns false if the session could not be established.
#
# @param endpoint [SMTPClient::Endpoint]
# @return [Boolean]
def connect_to_endpoint(endpoint, allow_ssl: true)
if @source_ip_address && @source_ip_address.ipv6.blank? && endpoint.ipv6?
# Don't try to use IPv6 if the IP address we're sending from doesn't support it.
return false
end
# Add this endpoint to the list of endpoints that we have attempted to connect to
@endpoints << endpoint unless @endpoints.include?(endpoint)
endpoint.start_smtp_session(allow_ssl: allow_ssl, source_ip_address: @source_ip_address)
logger.info "Connected to #{endpoint}"
@current_endpoint = endpoint
true
rescue StandardError => e
# Disconnect the SMTP client if we get any errors to avoid leaving
# a connection around.
endpoint.finish_smtp_session
# If we get an SSL error, we can retry a connection without
# ssl.
if e.is_a?(OpenSSL::SSL::SSLError) && endpoint.server.ssl_mode == "Auto"
logger.error "SSL error (#{e.message}), retrying without SSL"
return connect_to_endpoint(endpoint, allow_ssl: false)
end
# Otherwise, just log the connection error and return false
logger.error "Cannot connect to #{endpoint} (#{e.class}: #{e.message})"
@connection_errors << e.message unless @connection_errors.include?(e.message)
false
end
# Create a new result object
#
# @param type [String] the type of result
# @param start_time [Time] the time the operation started
# @yieldparam [SendResult] the result object
# @yieldreturn [void]
#
# @return [SendResult]
def create_result(type, start_time = nil)
result = SendResult.new
result.type = type
result.log_id = @log_id
result.secure = @current_endpoint&.smtp_client&.secure_socket? ? true : false
yield result if block_given?
if start_time
result.time = (Time.now - start_time).to_f.round(2)
end
result
end
def logger
@logger ||= Postal.logger.create_tagged_logger(log_id: @log_id)
end
class << self
# Return an array of SMTP relays as configured. Returns nil
# if no SMTP relays are configured.
#
def smtp_relays
return @smtp_relays if instance_variable_defined?("@smtp_relays")
relays = Postal::Config.postal.smtp_relays
return nil if relays.nil?
relays = relays.filter_map do |relay|
next unless relay.host.present?
SMTPClient::Server.new(relay.host, port: relay.port, ssl_mode: relay.ssl_mode)
end
@smtp_relays = relays.empty? ? nil : relays
end
end
end
================================================
FILE: app/services/webhook_delivery_service.rb
================================================
# frozen_string_literal: true
class WebhookDeliveryService
RETRIES = { 1 => 2.minutes, 2 => 3.minutes, 3 => 6.minutes, 4 => 10.minutes, 5 => 15.minutes }.freeze
def initialize(webhook_request:)
@webhook_request = webhook_request
end
def call
logger.tagged(webhook: @webhook_request.webhook_id, webhook_request: @webhook_request.id) do
generate_payload
send_request
record_attempt
appreciate_http_result
update_webhook_request
end
end
def success?
@success == true
end
private
def generate_payload
@payload = {
event: @webhook_request.event,
timestamp: @webhook_request.created_at.to_f,
payload: @webhook_request.payload,
uuid: @webhook_request.uuid
}.to_json
end
def send_request
@http_result = Postal::HTTP.post(@webhook_request.url,
sign: true,
json: @payload,
timeout: 5)
@success = (@http_result[:code] >= 200 && @http_result[:code] < 300)
end
def record_attempt
@webhook_request.attempts += 1
if success?
@webhook_request.retry_after = nil
else
@webhook_request.retry_after = RETRIES[@webhook_request.attempts]&.from_now
end
@attempt = @webhook_request.server.message_db.webhooks.record(
event: @webhook_request.event,
url: @webhook_request.url,
webhook_id: @webhook_request.webhook_id,
attempt: @webhook_request.attempts,
timestamp: Time.now.to_f,
payload: @webhook_request.payload.to_json,
uuid: @webhook_request.uuid,
status_code: @http_result[:code],
body: @http_result[:body],
will_retry: @webhook_request.retry_after.present?
)
end
def appreciate_http_result
if success?
logger.info "Received #{@http_result[:code]} status code. That's OK."
@webhook_request.destroy!
@webhook_request.webhook&.update_column(:last_used_at, Time.current)
return
end
logger.error "Received #{@http_result[:code]} status code. That's not OK."
@webhook_request.error = "Couldn't send to URL. Code received was #{@http_result[:code]}"
end
def update_webhook_request
if @webhook_request.retry_after
logger.info "Will retry #{@webhook_request.retry_after} (this was attempt #{@webhook_request.attempts})"
@webhook_request.locked_by = nil
@webhook_request.locked_at = nil
@webhook_request.save!
return
end
logger.info "Have tried #{@webhook_request.attempts} times. Giving up."
@webhook_request.destroy!
end
def logger
Postal.logger
end
end
================================================
FILE: app/util/has_prometheus_metrics.rb
================================================
# frozen_string_literal: true
module HasPrometheusMetrics
def register_prometheus_counter(name, **kwargs)
counter = Prometheus::Client::Counter.new(name, **kwargs)
registry.register(counter)
end
def register_prometheus_histogram(name, **kwargs)
histogram = Prometheus::Client::Histogram.new(name, **kwargs)
registry.register(histogram)
end
def increment_prometheus_counter(name, labels: {})
counter = registry.get(name)
return if counter.nil?
counter.increment(labels: labels)
end
def observe_prometheus_histogram(name, time, labels: {})
histogram = registry.get(name)
return if histogram.nil?
histogram.observe(time, labels: labels)
end
private
def registry
Prometheus::Client.registry
end
end
================================================
FILE: app/util/health_server.rb
================================================
# frozen_string_literal: true
require "socket"
require "rackup/handler/webrick"
require "prometheus/client/formats/text"
class HealthServer
def initialize(name: "unnamed-process")
@name = name
end
def call(env)
case env["PATH_INFO"]
when "/health"
ok
when "/metrics"
metrics
when "/"
root
else
not_found
end
end
private
def root
[200, { "Content-Type" => "text/plain" }, ["#{@name} (pid: #{Process.pid}, host: #{hostname})"]]
end
def ok
[200, { "Content-Type" => "text/plain" }, ["OK"]]
end
def not_found
[404, { "Content-Type" => "text/plain" }, ["Not Found"]]
end
def metrics
registry = Prometheus::Client.registry
body = Prometheus::Client::Formats::Text.marshal(registry)
[200, { "Content-Type" => "text/plain" }, [body]]
end
def hostname
Socket.gethostname
rescue StandardError
"unknown-hostname"
end
class << self
def run(default_port:, default_bind_address:, **options)
port = ENV.fetch("HEALTH_SERVER_PORT", default_port)
bind_address = ENV.fetch("HEALTH_SERVER_BIND_ADDRESS", default_bind_address)
Rackup::Handler::WEBrick.run(new(**options),
Port: port,
BindAddress: bind_address,
AccessLog: [],
Logger: LoggerProxy.new)
rescue Errno::EADDRINUSE
Postal.logger.info "health server port (#{bind_address}:#{port}) is already " \
"in use, not starting health server"
end
def start(**options)
thread = Thread.new { run(**options) }
thread.abort_on_exception = false
thread
end
end
class LoggerProxy
[:info, :debug, :warn, :error, :fatal].each do |severity|
define_method(severity) do |message|
add(severity, message)
end
define_method("#{severity}?") do
severity != :debug
end
end
def add(severity, message)
return if severity == :debug
case message
when /\AWEBrick::HTTPServer#start:.*port=(\d+)/
Postal.logger.info "started health server on port #{::Regexp.last_match(1)}", component: "health-server"
when /\AWEBrick::HTTPServer#start done/
Postal.logger.info "stopped health server", component: "health-server"
when /\AWEBrick [\d.]+/,
/\Aruby ([\d.]+)/,
/\ARackup::Handler::WEBrick is mounted/,
/\Aclose TCPSocket/,
/\Agoing to shutdown/
# Don't actually print routine messages to avoid too much
# clutter when processes start it
else
Postal.logger.debug message, component: "health-server"
end
end
end
end
================================================
FILE: app/util/user_creator.rb
================================================
# frozen_string_literal: true
require "highline"
module UserCreator
class << self
def start(&block)
cli = HighLine.new
puts "\e[32mPostal User Creator\e[0m"
puts "Enter the information required to create a new Postal user."
puts "This tool is usually only used to create your initial admin user."
puts
user = User.new
user.email_address = cli.ask("E-Mail Address".ljust(20, " ") + ": ")
user.first_name = cli.ask("First Name".ljust(20, " ") + ": ")
user.last_name = cli.ask("Last Name".ljust(20, " ") + ": ")
user.password = cli.ask("Initial Password".ljust(20, " ") + ": ") { |value| value.echo = "*" }
block.call(user) if block_given?
puts
if user.save
puts "User has been created with e-mail address \e[32m#{user.email_address}\e[0m"
else
puts "\e[31mFailed to create user\e[0m"
user.errors.full_messages.each do |error|
puts " * #{error}"
end
end
puts
end
end
end
================================================
FILE: app/views/address_endpoints/_form.html.haml
================================================
= form_for [organization, @server, @address_endpoint], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :address, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :address, :autofocus => true, :class => 'input input--text'
.fieldSetSubmit.buttonSet
= f.submit @address_endpoint.new_record? ? "Create address endpoint" : "Save address endpoint", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete address endpoint", [organization, @server, @address_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this HTTP endpoint?\n\r#{pluralize @address_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted."}
= hidden_field_tag 'return_to', params[:return_to]
= hidden_field_tag 'return_notice', params[:return_notice]
================================================
FILE: app/views/address_endpoints/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "Address Endpoints"
- page_title << "Edit"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :address_endpoints
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/address_endpoints/index.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "Address Endpoints"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :address_endpoints
.pageContent.pageContent--compact
- if @address_endpoints.empty?
.noData.noData--clean
%h2.noData__title There aren't any address endpoints yet.
%p.noData__text
Address endpoints are e-mail addresses hosted on other platforms that you'd
like to deliver e-mails to. Once you've created these, you can send messages
to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.
%p.noData__button
= link_to "Add your first address endpoint", [:new, organization, @server, :address_endpoint], :class => 'button button--positive'
- else
%ul.endpointList.u-margin
- for endpoint in @address_endpoints
%li.endpointList__item
= link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do
.endpointList__main
%p.endpointList__name= endpoint.address
%ul.endpointList__details
%li.endpointList__detailItem
- if endpoint.last_used_at
Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago
- else
Not used yet
%p.u-center= link_to "Add another address endpoint", [:new, organization, @server, :address_endpoint], :class => 'button button--positive'
================================================
FILE: app/views/address_endpoints/new.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "Address Endpoints"
- page_title << "New"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :address_endpoints
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/app_mailer/password_reset.text.erb
================================================
Hello there,
You (or someone pretending to be you) have requested a new password for your Postal account. To choose a new password, please click the link below and you'll be able to create a new password and login to Postal.
<%= Postal.host_with_protocol %>/login/reset/<%= @user.password_reset_token %><%= @return_to ? "?return_to=#{ERB::Util.url_encode(@return_to)}" : '' %>
If you didn't request this, you can ignore this e-mail.
Thanks,
<%= Postal::Config.smtp.from_name %>
<%= Postal::Config.smtp.from_address %>
================================================
FILE: app/views/app_mailer/server_send_limit_approaching.text.erb
================================================
We're writing to let you know that your <%= @server.name %> mail server is approaching its send limit. All mail servers have a limit of how much e-mail they are permitted to send in a rolling 60 minute window. At present you have sent <%= @server.send_volume %> messages and have a limit of <%= @server.send_limit %>.
Orgaization: <%= @server.organization.name %>
Server: <%= @server.name %>
Send Limit: <%= @server.send_limit %>
Current Volume: <%= @server.send_volume %>
When you reach your limit, any mail you send will be held in the system until it is manually unheld by you through the web interface or using the API.
You can view more information about this server at:
<%= Postal.host_with_protocol %>/org/<%= @server.organization.permalink %>/servers/<%= @server.permalink %>
Thanks,
<%= Postal::Config.smtp.from_name %>
<%= Postal::Config.smtp.from_address %>
================================================
FILE: app/views/app_mailer/server_send_limit_exceeded.text.erb
================================================
We're writing to let you know that your <%= @server.name %> mail server has exceeded its send limit. All mail servers have a limit of how much e-mail they are permitted to send in a rolling 60 minute window. At present you have sent <%= @server.send_volume %> messages and have a limit of <%= @server.send_limit %>.
Orgaization: <%= @server.organization.name %>
Server: <%= @server.name %>
Send Limit: <%= @server.send_limit %>
Current Volume: <%= @server.send_volume %>
All messages that you send until your volume drops will now be held in the system. You will need to manually release any of these messages that you wish to send. You can do this through the web interface or using the API.
You can view more information about this server at:
<%= Postal.host_with_protocol %>/org/<%= @server.organization.permalink %>/servers/<%= @server.permalink %>
Thanks,
<%= Postal::Config.smtp.from_name %>
<%= Postal::Config.smtp.from_address %>
================================================
FILE: app/views/app_mailer/server_suspended.text.erb
================================================
Hello,
We're writing to inform you that, unfortunately, we have had to suspend one of your mail servers on Postal.
Organization: <%= @server.organization.name %>
Server: <%= @server.name %>
Reason: <%= @server.actual_suspension_reason %>
Thanks,
<%= Postal::Config.smtp.from_name %>
<%= Postal::Config.smtp.from_address %>
================================================
FILE: app/views/app_mailer/test_message.text.erb
================================================
This is a test message sent by Postal.
If you have received this message your test has succeeded.
================================================
FILE: app/views/app_mailer/verify_domain.text.erb
================================================
Hello there,
<%= @user.name %> (<%= @domain.owner.is_a?(Organization) ? @domain.owner.name : @domain.owner.organization.name %>) would like to start sending e-mail from <%= @domain.name %> using Postal. We're writing to you to request your authorization to allow this domain to be used to send e-mail through their mail server.
If you agree, please provide the code below to <%= @user.first_name %> who will be able to enter it into our web interface to continue.
<%= @domain.verification_token %>
If you don't agree, just ignore this e-mail.
Thanks,
<%= Postal::Config.smtp.from_name %>
<%= Postal::Config.smtp.from_address %>
================================================
FILE: app/views/credentials/_form.html.haml
================================================
= form_for [organization, @server, @credential], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :type, :class => 'fieldSet__label'
.fieldSet__input
= f.select :type, Credential::TYPES, {}, :disabled => @credential.persisted?, :class => 'input input--select', :autofocus => @credential.new_record?
%p.fieldSet__text
This is the service that is associated with this credential. You'll be able to use this key to
authenticate to this type of service only.
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :name, :autofocus => @credential.persisted?, :class => 'input input--text'
%p.fieldSet__text
This is a friendly name so you can identify this credential later. You can enter anything
you want here, the more descriptive the better.
.fieldSet__field{data: {credential_key_type: 'all'}}
= f.label :key, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :key, :readonly => false, :class => 'input input--text input--code', :placeholder => "Automatically generated", :tabindex => 1000, :value => (@credential.new_record? ? '' : @credential.key)
%p.fieldSet__text
This is the unique key which will be used to authenticate any requests to the API or the SMTP servers.
It will be generated randomly and cannot be changed. If you need a new token, you can create a new one and then
delete the old one when you're ready.
.fieldSet__field{data: {credential_key_type: 'smtp-ip'}}
= f.label :key, "Network", :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :key, :class => 'input input--text input--code'
%p.fieldSet__text
This is the IP address or network that you wish to allow to authenticate to this mail server.
.fieldSet__field
= f.label :hold, :class => 'fieldSet__label'
.fieldSet__input
= f.select :hold, [["Process all messages", false], ["Hold messages from this credential", true]], {}, :class => 'input input--select'
%p.fieldSet__text
You may wish to automatically hold all messages that are sent by this credential. This allows you to preview them
before they are delivered to their recipients. This is useful for credentials for development environments.
.fieldSetSubmit.buttonSet
= f.submit @credential.new_record? ? "Create credential" : "Save credential", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete credential", [organization, @server, @credential], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this credential?"}
================================================
FILE: app/views/credentials/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Credentials"
- page_title << "Edit"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :credentials
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/credentials/index.html.haml
================================================
- page_title << @server.name
- page_title << "Credentials"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :credentials
.pageContent.pageContent--compact
- if @credentials.empty?
.noData.noData--clean
%h2.noData__title There are no credentials for this server.
%p.noData__text
In order to authenticate to your mail server, you use credentials. Once
you've added a credential, you'll have a unique token which you can use to
authenticate against our SMTP service or our HTTP API.
.noData__button= link_to "Add your first credential", [:new, organization, @server, :credential], :class => 'button button--positive'
- else
%p.pageContent__intro.u-margin
In order to authenticate to your mail server, you use credentials. Once
you've added a credential, you'll have a unique token which you can use to
authenticate against our SMTP service or our HTTP API.
%p.u-margin.pageContent__helpLink= link_to "Read more about sending outgoing e-mails", [organization, @server, :help_outgoing]
%ul.credentialList.u-margin
- for credential in @credentials
%li.credentialList__item
= link_to [:edit, organization, @server, credential], :class => 'credentialList__link' do
.credentialList__type
%span.label{:class => "label--credentialType-#{credential.type.underscore}"}= credential.type.split('-').last
.credentialList__properties
%p.credentialList__name
= credential.name
- if credential.hold?
%span.label.label--red Holding
%p.credentialList__key= credential.key
.credentialList__usedAt{:class => "credentialList__usedAt--#{credential.usage_type.underscore}"}
- if credential.last_used_at
%p.credentialList__usedAtTitle= credential.usage_type
%p Used #{distance_of_time_in_words_to_now credential.last_used_at} ago
- else
%p Not been used yet
%p.u-center.buttonSet.buttonSet--center
= link_to "Add another credential", [:new, organization, @server, :credential], :class => 'button button--positive'
================================================
FILE: app/views/credentials/new.html.haml
================================================
- page_title << @server.name
- page_title << "Credentials"
- page_title << "Add Credential"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :credentials
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/domains/_nav.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "Domains", organization_server_domains_path(organization, @server), :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']
%li.navBar__item= link_to "Tracking Domains", organization_server_track_domains_path(organization, @server), :class => ['navBar__link', active_nav == :track_domains ? 'is-active' : '']
================================================
FILE: app/views/domains/_verify_with_dns.html.haml
================================================
%p.pageContent__intro.u-margin
To verify your ownership of #{@domain.name} , you need to add a TXT record to this domain.
The TXT record should point to the domain and include the value shown below.
%pre.codeBlock.u-margin= @domain.dns_verification_string
%p.pageContent__intro.u-margin
Once you've added this, click the button below to verify the presence of this record and
verify your domain.
.buttonSet
= link_to "Verify TXT record", [:verify, organization, @server, @domain], :remote => true, :method => :post, :class => "button"
= link_to "Back to domain list", [organization, @server, :domains], :class => "button button--neutral"
================================================
FILE: app/views/domains/_verify_with_email.html.haml
================================================
- if params[:email_address]
%p.pageContent__intro.u-margin
We've sent an email to #{params[:email_address]} . Please check your e-mail and enter
the code you've been sent in the box below.
= form_tag request.fullpath, :remote => true do
= hidden_field_tag 'email_address', params[:email_address]
%p.u-margin
= text_field_tag "code", params[:code], :autofocus => true, :class => 'input input--text js-multibox'
.buttonSet
= submit_tag "Verify this domain", :class => 'button js-form-submit'
= link_to "Back to domain list", [organization, @server, :domains], :class => "button button--neutral"
- else
%p.pageContent__intro.u-margin
To verify your ownership of #{@domain.name} by e-mail, choose an e-mail address from the list
below. We'll then send you an email with a code which you'll need to enter below.
= form_tag request.fullpath, :remote => true do
%p.u-margin
= select_tag "email_address", options_for_select(@domain.verification_email_addresses), :class => 'input input--select', :autofocus => true
%p.buttonSet
= submit_tag "Continue", :class => 'button'
= link_to "Back to domain list", [organization, @server, :domains], :class => "button button--neutral"
================================================
FILE: app/views/domains/index.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << "Domains"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'nav', :active_nav => :domains
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
Domains
= render 'organizations/nav', :active_nav => :domains
.pageContent.pageContent--compact
- if @domains.empty?
.noData.noData--clean
%h2.noData__title There are no domains for this server.
%p.noData__text
To send & receive messages you need to add & verify the domain you wish to send/receive
messages to/from. Add your domain below to get started.
%p.noData__button= link_to "Add your first domain", [:new, organization, @server, :domain], :class => "button button--positive"
- else
%ul.domainList.u-margin
- for domain in @domains
%li.domainList__item
.domainList__details
%p.domainList__name
= link_to domain.name, [:setup, organization, @server, domain]
- if domain.use_for_any?
%span.label.label--blue Any
%ul.domainList__checks
- if domain.spf_status == 'OK'
%li.domainList__check.domainList__check--ok SPF
- elsif domain.spf_status.nil?
- else
%li.domainList__check.domainList__check--warning{:title => domain.spf_error}= link_to "SPF", [:setup, organization, @server, domain]
- if domain.dkim_status == 'OK'
%li.domainList__check.domainList__check--ok DKIM
- elsif domain.dkim_status.nil?
- else
%li.domainList__check.domainList__check--warning{:title => domain.dkim_error}= link_to "DKIM", [:setup, organization, @server, domain]
- if domain.mx_status == 'OK'
%li.domainList__check.domainList__check--ok MX
- elsif domain.mx_status.nil?
- else
%li.domainList__check.domainList__check--neutral-cross{:title => domain.mx_error}= link_to "MX", [:setup, organization, @server, domain]
- if domain.return_path_status == 'OK'
%li.domainList__check.domainList__check--ok Return Path
- elsif domain.return_path_status.nil?
- elsif domain.return_path_status == 'Missing'
%li.domainList__check.domainList__check--neutral{:title => domain.return_path_error}= link_to "Return Path", [:setup, organization, @server, domain]
- else
%li.domainList__check.domainList__check--warning{:title => domain.return_path_error}= link_to "Return Path", [:setup, organization, @server, domain]
%ul.domainList__properties
- if domain.verified?
%li.domainList__verificationTime Verified on #{domain.verified_at.to_fs(:long)}
- else
%li= link_to "Verify this domain", [:verify, organization, @server, domain], :class => "domainList__verificationLink"
%li.domainList__links
- if domain.verified?
= link_to "DNS setup", [:setup, organization, @server, domain]
= link_to "Delete", [organization, @server, domain], :remote => :delete, :method => :delete, :data => {:confirm => "Are you sure you wish to remove this domain?", :disable_with => "Deleting..."}, :class => 'domainList__delete'
%p.u-center= link_to "Add new domain", [:new, organization, @server, :domain], :class => "button button--positive"
================================================
FILE: app/views/domains/new.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << "Add Domain"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'nav', :active_nav => :domains
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→ Domains →
Add new domain
= render 'organizations/nav', :active_nav => :domains
.pageContent.pageContent--compact
= form_for [organization, @server, @domain], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
- unless current_user.admin?
.fieldSet__field
= f.label :verification_method, :class => 'fieldSet__label'
.fieldSet__input
= f.select :verification_method, Domain::VERIFICATION_METHODS, {}, :class => 'input input--select'
.fieldSet__text
Choose how you'd like to verify your ownership of this domain. If you choose E-Mail we can send you
an email with a code whcih you'll need to enter - you can choose from a set of pre-defined addresses for
the domain. Using DNS you'll need to add a TXT record on this domain using your DNS provider.
.fieldSetSubmit
= f.submit :class => "button button--positive js-form-submit"
================================================
FILE: app/views/domains/setup.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << @domain.name
- page_title << "DNS Setup"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'nav', :active_nav => :domains
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→ Domains →
= @domain.name
= render 'organizations/nav', :active_nav => :domains
.pageContent.pageContent--compact
%h2.pageContent__title DNS Setup for #{@domain.name}
%p.pageContent__intro.u-margin
Follow the instructions below to configure SPF & DKIM records for this domain.
We highly recommend that you do this to ensure your messages are delivered
correctly and quickly.
.u-margin.buttonSet
= link_to "Check my records are correct", [:check, organization, @server, @domain], :remote => true, :method => :post, :class => 'button'
= link_to "Back to domain list", [organization, @server, :domains], :class => 'button button--neutral'
- if @domain.dns_checked_at
%p.u-margin We last checked the validity of your DNS records #{distance_of_time_in_words_to_now @domain.dns_checked_at} ago.
%h3.pageContent__subTitle SPF Record
- if @domain.spf_status == 'OK'
%p.pageContent__text.u-green.u-bold
%span.label.label--green Good
Your SPF record looks good!
- elsif !@domain.spf_status.nil?
%p.pageContent__text.u-orange.u-bold
%span.label.label--orange Warning
= @domain.spf_error
%p.pageContent__text
You need to add a TXT record at the apex/root of your domain (@) with the following
content. If you already send mail from another service, you may just need to add
include:#{Postal::Config.dns.spf_include} to your existing record.
%pre.codeBlock.u-margin= @domain.spf_record
%h3.pageContent__subTitle DKIM Record
- if @domain.dkim_status == 'OK'
%p.pageContent__text.u-green.u-bold
%span.label.label--green Good
Your DKIM record looks good!
- elsif !@domain.dkim_status.nil?
%p.pageContent__text.u-orange.u-bold
%span.label.label--orange Warning
= @domain.dkim_error
%p.pageContent__text
You need to add a new TXT record with the name #{@domain.dkim_record_name}
with the following content.
%pre.codeBlock.u-margin= @domain.dkim_record
%h3.pageContent__subTitle Return Path
- if @domain.return_path_status == 'OK'
%p.pageContent__text.u-green.u-bold
%span.label.label--green Good
Your return path looks good. We'll use this when sending e-mail from this domain.
- elsif @domain.return_path_status == 'Missing'
%p.pageContent__text.u-grey.u-bold
%span.label.label--grey OK
There's no return path for this domain. This is OK but we recommend adding the record to improve deliverability and achieve DMARC alignment.
- elsif !@domain.return_path_status.nil?
%p.pageContent__text.u-orange.u-bold
%span.label.label--orange Warning
= @domain.return_path_error
%p.pageContent__text
This is optional but we recommend adding this to improve deliverability. You should add
a CNAME record at #{@domain.return_path_domain} to point to the hostname below.
%pre.codeBlock.u-margin= Postal::Config.dns.return_path_domain
%h3.pageContent__subTitle MX Records
- if @domain.mx_status == 'OK'
%p.pageContent__text.u-green.u-bold
%span.label.label--green Good
Your MX records look like they're good to go!
- elsif @domain.mx_status == 'Missing'
%p.pageContent__text.u-grey.u-bold
%span.label.label--grey OK
None of the MX records for this domain point to us. Incoming mail won't be sent to us.
- elsif !@domain.mx_status.nil?
%p.pageContent__text.u-orange.u-bold
%span.label.label--orange Warning
= @domain.mx_error
%p.pageContent__text
If you wish to receive incoming e-mail for this domain, you need to add the following MX records
to the domain. You don't have to do this and we'll only tell you if they're set up or not. Both
records should be priority 10 .
%pre.codeBlock.u-margin= Postal::Config.dns.mx_records.join("\n")
================================================
FILE: app/views/domains/verify.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << @domain.name
- page_title << "Verify"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'nav', :active_nav => :domains
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→ Domains →
= @domain.name
= render 'organizations/nav', :active_nav => :domains
.pageContent.pageContent--compact
= render :partial => "verify_with_#{@domain.verification_method.underscore}"
================================================
FILE: app/views/help/_header.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "Sending E-Mail", [organization, @server, :help_outgoing], :class => ['navBar__link', active_nav == :outgoing ? 'is-active' : '']
%li.navBar__item= link_to "Receiving E-Mail", [organization, @server, :help_incoming], :class => ['navBar__link', active_nav == :incoming ? 'is-active' : '']
================================================
FILE: app/views/help/incoming.html.haml
================================================
- page_title << @server.name
- page_title << "Help"
- page_title << "Receiving E-Mail"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :help
= render 'header', :active_nav => :incoming
.pageContent.pageContent--compact
%h1.pageContent__title Receiving e-mail
%h2.pageContent__intro.u-margin
This system can handle your incoming e-mail by accepting it from other mail servers and
sending it on to your own applications using HTTP or to forward it to other SMTP servers.
%p.u-margin.pageContent__helpLink= link_to "Read more about sending e-mails", [organization, @server, :help_outgoing]
.u-margin
%h2.pageContent__subTitle Forwarding e-mails
%p.pageContent__text
If you already have a incoming mail server for your domain, you may find the quickest
way to get up and running is to simply forward e-mail from that server.
You don't need to make any changes to your DNS to do this.
%p.pageContent__text
Just #{link_to "create an incoming route", [organization, @server, :routes], :class => "u-link"}
for the address you want to receive messages for and then you'll be provided with
an e-mail address that messages can be forward to. Any message that is received to
this address will be treated as if it had been sent directly to the address on the route.
%p.pageContent__text
The address to forward mail to can be found by clicking on the route and copying the
field marked Address from the form.
.u-margin
%h2.pageContent__subTitle Setting your MX records
%p.pageContent__text
If you don't already have a mail server on your domain, you can simply set your
MX records to point to this system. The MX records are shown
below and you should add these both as priority 10 in your DNS configuration. Once
these have been added successfully they will show with a green tick on your domain list.
%dl.pageContent__definitions
%dt MX Records
%dd
- for mx in Postal::Config.dns.mx_records
%p.pageContent__definitionCode= mx
================================================
FILE: app/views/help/outgoing.html.haml
================================================
- page_title << @server.name
- page_title << "Help"
- page_title << "Sending E-Mail"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :help
= render 'header', :active_nav => :outgoing
.pageContent.pageContent--compact
%h1.pageContent__title Sending e-mail
%h2.pageContent__intro.u-margin
There are a couple of different ways you send outgoing mail through a
mail server. These methods are shown below:
%p.u-margin.pageContent__helpLink= link_to "Read more about receiving e-mails", [organization, @server, :help_incoming]
.u-margin
%h2.pageContent__subTitle Important notes
%ul.pageContent__list
%li
E-mails can only be sent from addresses with domains that you have added to mail server or the server's organization.
Mail servers can be enabled to send mail from any domain by the administrator.
%li
If a message cannot be delivered, the system will not send you a bounce message but dispatch a webhook (if you set one up).
If a message delivery fails but can be retried, the system will try #{Postal::Config.postal.default_maximum_delivery_attempts} times to deliver it before giving up.
.u-margin
%h2.pageContent__subTitle Sending using SMTP
%p.pageContent__text
These instructions explain how to send messages using the SMTP server.
%dl.pageContent__definitions
%dt SMTP Server Address
%dd
%p.pageContent__definitionCode= Postal::Config.postal.smtp_hostname
%dt Port
%dd
%p.pageContent__definitionCode= Postal::Config.smtp_server.default_port
%p.pageContent__definitionText
The SMTP service supports STARTTLS if you wish to send messages securely. Be aware that security
cannot guaranteed all the way to their final destination.
%dt Username
%dd
%p.pageContent__definitionCode= @server.full_permalink
%dt Password
%dd
- if @credentials['SMTP'].present?
%p.pageContent__definitionCode
= @credentials['SMTP'].first.key
%p.pageContent__definitionText= link_to "Create more credentials", [organization, @server, :credentials], :class => "u-link"
- else
%p.warningBox
%b No SMTP credentials created for this server yet.
A password can be generated from the #{link_to 'credentials', [:new, organization, @server, :credential], :class => "u-link"}
page. Just create a credential with the SMTP type and add a name which suits the place you'll be using the credentials.
%dt Authentication Methods
%dd
%p.pageContent__definitionCode PLAIN, LOGIN or CRAM-MD5
.u-margin
%h2.pageContent__subTitle Sending over HTTP using our API
%p.pageContent__text
For full information about how to use our HTTP API, please #{link_to 'see the documentation', 'https://docs.postalserver.io/developer/api', :class => "u-link"}.
================================================
FILE: app/views/http_endpoints/_form.html.haml
================================================
= form_for [organization, @server, @http_endpoint], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
.fieldSet__field
= f.label :url, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :url, :class => 'input input--text'
%p.fieldSet__text
Enter the full URL that we should POST your messages to. We recommend using https URLs here to
ensure your data remains secure in transit.
.fieldSet__field
= f.label :encoding, :class => 'fieldSet__label'
.fieldSet__input
= f.select :encoding, HTTPEndpoint::ENCODINGS.map { |e| [t("http_endpoint_encodings.#{e.underscore}"), e] }, {}, :class => 'input input--select'
%p.fieldSet__text
You can choose how the data will be delivered to your server. We recommend receiving data as JSON which will be
posted to your endpoint with an application/json content type. If you choose to use form data, you'll be able
to read parameters as normal without parsing any JSON.
.fieldSet__field
= f.label :format, :class => 'fieldSet__label'
.fieldSet__input
= f.select :format, HTTPEndpoint::FORMATS.map { |e| [t("http_endpoint_formats.#{e.underscore}"), e] }, {}, :class => 'input input--select'
%p.fieldSet__text
You can choose whether to receive the full raw message or whether you'd prefer to receive a individual properties
for a message individually.
.fieldSet__field
= f.label :strip_replies, :class => 'fieldSet__label'
.fieldSet__input
= f.select :strip_replies, [["Send the full message as received", false], ["Try to separate replies/signatures from plain body", true]], {}, :class => 'input input--select'
%p.fieldSet__text
If enabled, we'll try to remove the replies/signatures from the plain body and send them separately to the rest of the body.
This is useful if you just want to see the latest message in a thread.
.fieldSet__field
= f.label :include_attachments, "Attachments", :class => 'fieldSet__label'
.fieldSet__input
= f.select :include_attachments, [["Include attachment data", true], ["Don't include attachment data", false]], {}, :class => 'input input--select'
%p.fieldSet__text
You can choose whether or not attachment data will be delivered to your app. This only applies when the message is delivered
as a hash (rather than the raw message - these will always have attachment data within).
.fieldSet__field
= f.label :timeout, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :timeout, :class => 'input input--text', :placeholder => "Default: 5"
%p.fieldSet__text
This is how long (in seconds) we should wait for your server to respond before giving up and trying again later. By default this is 5
seconds. The maximum value is 60 seconds.
.fieldSetSubmit.buttonSet
= f.submit @http_endpoint.new_record? ? "Create HTTP endpoint" : "Save HTTP endpoint", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete HTTP endpoint", [organization, @server, @http_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this HTTP endpoint?\n\r#{pluralize @http_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted."}
= hidden_field_tag 'return_to', params[:return_to]
= hidden_field_tag 'return_notice', params[:return_notice]
================================================
FILE: app/views/http_endpoints/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "HTTP Endpoints"
- page_title << "Edit"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :http_endpoints
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/http_endpoints/index.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "HTTP Endpoints"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :http_endpoints
.pageContent.pageContent--compact
- if @http_endpoints.empty?
.noData.noData--clean
%h2.noData__title There aren't any HTTP endpoints yet.
%p.noData__text
HTTP endpoints are essentially URLs that you'd like incoming e-mails
to be delivered to. Once you've added some endpoints, you can route messages
to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.
%p.noData__button
= link_to "Add your first HTTP endpoint", [:new, organization, @server, :http_endpoint], :class => 'button button--positive'
- else
%ul.endpointList.u-margin
- for endpoint in @http_endpoints
%li.endpointList__item
= link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do
.endpointList__main
%p.endpointList__name= endpoint.name
%p.endpointList__url= endpoint.url
%ul.endpointList__details
%li.endpointList__detailItem= t("http_endpoint_encodings.#{endpoint.encoding.underscore}")
%li.endpointList__detailItem= t("http_endpoint_formats.#{endpoint.format.underscore}")
%li.endpointList__detailItem
- if endpoint.last_used_at
Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago
- else
Not used yet
%p.u-center= link_to "Add another HTTP endpoint", [:new, organization, @server, :http_endpoint], :class => 'button button--positive'
================================================
FILE: app/views/http_endpoints/new.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "HTTP Endpoints"
- page_title << "New"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :http_endpoints
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/ip_addresses/_form.html.haml
================================================
= form_for [@ip_pool, @ip_address], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :ipv4, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :ipv4, :autofocus => true, :class => 'input input--text'
.fieldSet__field
= f.label :ipv6, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :ipv6, :class => 'input input--text'
.fieldSet__field
= f.label :hostname, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :hostname, :class => 'input input--text'
.fieldSet__field
= f.label :priority, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :priority, :class => 'input input--text', placeholder: '100'
%p.fieldSet__text
This priority will determine the likelihood of this IP address being selected
for use when sending a message. The higher the number the more likely the IP
is to be chosen. By default, the priority is set to the maximum value of 100.
This can be used to warm up new IP addresses by adding them with a low priority.
To give an indication of how this works, if you have three IPs with 1, 50 and 100
as their priorities, and you send 100,000 emails, the priority 1 address will receive
a tiny percentage, the priority 50 will receive roughly one third of e-mails and the
priority 100 will receive roughly two thirds.
.fieldSetSubmit.buttonSet
= f.submit :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if @ip_address.persisted?
= link_to "Delete IP address", [@ip_pool, @ip_address], :class => 'button button--danger', :method => :delete, :remote => true, :data => {:confirm => "Are you sure you wish to remove this IP from the pool?"}
================================================
FILE: app/views/ip_addresses/edit.html.haml
================================================
- page_title << "IP Pools"
- page_title << @ip_pool.name
- page_title << "Edit IP address"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= link_to "IP Pools", :ip_pools
→
= @ip_pool.name
→
Edit
→
Edit IP address
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/ip_addresses/new.html.haml
================================================
- page_title << "IP Pools"
- page_title << @ip_pool.name
- page_title << "Add new IP address"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= link_to "IP Pools", :ip_pools
→
= @ip_pool.name
→
Edit
→
Add new IP address
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/ip_pool_rules/_form.html.haml
================================================
.pageContent.pageContent--compact
= form_for [organization, @server, @ip_pool_rule], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
%h2.fieldSet__title.fieldSet__title--noMargin Rule match conditions
.fieldSet__field
= f.label :to_text, "To Addresses", :class => 'fieldSet__label'
.fieldSet__input
~ f.text_area :to_text, :autofocus => true, :class => 'input input--text input--smallArea'
%p.fieldSet__text
This is a list of addresses or domains which should be matched. This
applies to e-mail address of the recipient of a message.
.fieldSet__field
= f.label :from_text, "From Addresses", :class => 'fieldSet__label'
.fieldSet__input
~ f.text_area :from_text, :class => 'input input--text input--smallArea'
%p.fieldSet__text
This is a list of addresses or domains which should be matched. This
applies to value From in the From header of the message
that is being delivered.
%fieldset.fieldSet
%h2.fieldSet__title Selected IP Pool
.fieldSet__field
= f.label :ip_pool_id, "IP Pool", :class => 'fieldSet__label'
.fieldSet__input
= f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :name, {}, :class => 'input input--select'
%p.fieldSet__text
This is the IP pool that this message should be delivered from.
.fieldSetSubmit
= f.submit "Save Rule", :class => "button button--positive js-form-submit"
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete Rule", [organization, @server, @ip_pool_rule], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this rule?"}
================================================
FILE: app/views/ip_pool_rules/edit.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << "Edit IP Pool Rule"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :settings
= render 'servers/settings_header', :active_nav => :ip_pool_rules
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
IP Pool Rules
→
Edit rule
= render 'organizations/nav', :active_nav => :ips
= render 'organization_ip_pools/nav', :active_nav => :rules
= render 'form'
================================================
FILE: app/views/ip_pool_rules/index.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << "IP Pool Rules"
- else
- page_title << "IPs"
- page_title << "Rules"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :settings
= render 'servers/settings_header', :active_nav => :ip_pool_rules
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
IP Pool Rules
= render 'organizations/nav', :active_nav => :ips
= render 'organization_ip_pools/nav', :active_nav => :rules
.pageContent.pageContent--compact
- if @ip_pool_rules.empty?
.noData.noData--clean
- if @server.nil?
%h2.noData__title No global rules have been configured yet.
%p.noData__text
You can use IP pool rules to configure which IP addresses to use based on the
message that are passing through Postal. You can add rules globally or on a
per-server basis.
%p.noData__button= link_to "Add a global rule", [:new, organization, @server, :ip_pool_rule], :class => "button button--positive"
- else
%h2.noData__title No IP rules have been configured for this server yet.
%p.noData__text
You can use IP pool rules to configure which IP addresses to use based on the
message that are passing through Postal. You can add rules globally or on a
per-server basis.
%p.noData__button= link_to "Add a server rule", [:new, organization, @server, :ip_pool_rule], :class => "button button--positive"
-
- else
.ipPoolRuleList.u-margin
- for ip_pool_rule in @ip_pool_rules
.ipPoolRuleList__item
= link_to [:edit, organization, @server, ip_pool_rule], :class => 'ipPoolRuleList__link' do
- if ip_pool_rule.to.present?
%dl.ipPoolRuleList__condition
%dt Any messages sent to:
%dd
%ul
- for a in ip_pool_rule.to
%li= a
- if ip_pool_rule.from.present?
%dl.ipPoolRuleList__condition
%dt Any message sent from:
%dd
%ul
- for a in ip_pool_rule.from
%li= a
%dl.ipPoolRuleList__condition
%dt Will be sent using:
%dd= ip_pool_rule.ip_pool.name
- if @server && @server.ip_pool
%p.ipPoolRuleListDefault.u-margin All mail that doesn't match a rule above will be sent using #{@server.ip_pool.name}.
%p.u-center= link_to "Add another rule", [:new, organization, @server, :ip_pool_rule], :class => "button button--positive"
================================================
FILE: app/views/ip_pool_rules/new.html.haml
================================================
- if @server
- page_title << @server.name
- page_title << "Add IP Pool Rule"
- if @server
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :settings
= render 'servers/settings_header', :active_nav => :ip_pool_rules
- else
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
IP Pool Rules
→
Add new rule
= render 'organizations/nav', :active_nav => :ips
= render 'organization_ip_pools/nav', :active_nav => :rules
= render 'form'
================================================
FILE: app/views/ip_pools/_form.html.haml
================================================
= form_for @ip_pool, :remote => true do |f|
= f.error_messages
%fieldset.fieldSet.u-margin
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
- if @ip_pool.persisted?
%table.dataTable.u-margin-half
%thead
%tr
%td IPv4
%td IPv6
%td Hostname
%td Priority
%tbody
- ips = @ip_pool.ip_addresses.order_by_priority
- if ips.empty?
%tr
%td.dataTable__empty{:colspan => 3} There are no IP addresses assigned to this pool yet.
- else
- for ip in ips
%tr
%td{:width => "20%"}= link_to ip.ipv4, [:edit, @ip_pool, ip], :class => "u-link"
%td{:width => "35%"}= ip.ipv6
%td{:width => "35%"}= ip.hostname
%td{:width => "10%"}= ip.priority
%p= link_to "Add an IP address to pool", [:new, @ip_pool, :ip_address], :class => "u-link"
.fieldSetSubmit.buttonSet
= f.submit :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if @ip_pool.persisted?
= link_to "Delete IP pool", [@ip_pool], :class => 'button button--danger', :method => :delete, :remote => true, :data => {:confirm => "Are you sure you wish to remove this IP pool?"}
================================================
FILE: app/views/ip_pools/edit.html.haml
================================================
- page_title << "IP Pools"
- page_title << @ip_pool.name
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= link_to "IP Pools", :ip_pools
→
= @ip_pool.name
→
Edit
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/ip_pools/index.html.haml
================================================
- page_title << "Welcome"
.pageHeader
%h1.pageHeader__title IP Pools
.pageContent.pageContent--compact
- if @ip_pools.empty?
.noData.noData--clean
%p.noData__title There are no IP pools configured.
%p.noData__text
All messages sent from your mail server can be sent from certain pools of
IP addresses. Each server can be assigned to a pool and rules can be configured
to route certain email through certain pools.
%p.noData__button= link_to "Create the first IP pool", :new_ip_pool, :class => 'button button--positive'
- else
%p.pageContent__intro.u-margin
IP pools are the addresses that your outgoing messages are sent from. You can
create as many pools as you wish.
%ul.largeList.u-margin
- for ip_pool in @ip_pools
%li.largeList__item
= link_to edit_ip_pool_path(ip_pool), :class => 'largeList__link' do
= ip_pool.name
%p.u-center= link_to "Add another IP pool", :new_ip_pool, :class => 'button button--positive'
================================================
FILE: app/views/ip_pools/new.html.haml
================================================
- page_title << "Create a new IP pool"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= link_to "IP Pools", :ip_pools
→
Create a new IP pool
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/layouts/application.html.haml
================================================
!!!
%html.main
%head
%title #{page_title.reverse.join(' - ')}
= csrf_meta_tags
= stylesheet_link_tag 'application/application', 'data-turbolinks-track' => 'reload'
= javascript_include_tag 'application/application', 'data-turbolinks-track' => 'reload'
%link{:href => asset_path('favicon.png'), :rel => 'shortcut icon'}
= yield :head
%body
= display_flash
%header.siteHeader{'data-turbolinks-permanent' => true}
- if flash[:remember_login] && !auth_session.persistent?
.siteHeader__remember.js-remember
.siteHeader__rememberText
%p.siteHeader__rememberTextTitle Would you like to stay logged in?
%p This will keep you logged in in this browser for 2 months.
.siteHeader__rememberButtons.buttonSet
= link_to "Remember me", '#', :class => 'button button--positive button--small', :data => {:remember => 'yes'}
= link_to "Close", '#', :class => 'button button--dark button--small', :data => {:remember => 'no'}
.siteHeader__inside
.siteHeader__logo= link_to "Postal", root_path
%p.siteHeader__version The open source e-mail platform
%ul.siteHeader__nav
- if defined?(organization) && organization
%li.siteHeader__navItem
%li.siteHeader__navItem.siteHeader__navItem--organization
= link_to organization.name, organization_root_path(organization), :class => 'siteHeader__navLinkWithMenu'
%ul.siteHeader__subMenu
%li.siteHeader__subMenuItem.siteHeader__subMenuItem--header= link_to organization.name, organization_root_path(organization)
%li.siteHeader__subMenuItem= link_to "Mail servers", organization_root_path(organization), :class => 'siteHeader__subMenuLink'
%li.siteHeader__subMenuItem= link_to "Domains", organization_domains_path(organization), :class => 'siteHeader__subMenuLink'
%li.siteHeader__subMenuItem= link_to "Organization Settings", organization_settings_path(organization), :class => 'siteHeader__subMenuLink'
- if current_user.admin?
%li.siteHeader__subMenuItem= link_to "Create new organization", :new_organization, :class => 'siteHeader__subMenuLink'
- if current_user.organizations.present.count > 1
%li.siteHeader__subMenuItem= link_to "Switch organization", root_path, :class => 'siteHeader__subMenuLink'
%li.siteHeader__navItem.siteHeader__navItem--user= current_user.name
%li.siteHeader__navItem= link_to "My Settings", settings_path, :class => 'sideHeader__navItemLink'
- if current_user.admin?
- if Postal.ip_pools?
%li.siteHeader__navItem= link_to "IP Pools", ip_pools_path, :class => 'sideHeader__navItemLink'
%li.siteHeader__navItem= link_to "Users", users_path, :class => 'sideHeader__navItemLink'
%li.siteHeader__navItem= link_to "Logout", logout_path, :method => :delete, :class => 'sideHeader__navItemLink'
.siteContent
- if content_for?(:sidebar)
%nav.sidebar
= content_for :sidebar
%section.siteContent__main
= yield
%footer.siteContent__footer
%ul.footer__links
%li.footer__name
Powered by
#{link_to "Postal", "https://postalserver.io", target: '_blank'}
#{postal_version_string}
%li= link_to "Documentation", "https://docs.postalserver.io", target: '_blank'
%li= link_to "Ask for help", "https://discussions.postalserver.io", target: '_blank'
================================================
FILE: app/views/layouts/sub.html.haml
================================================
!!!
%html.subPage
%head
%title #{page_title.reverse.join(' - ')}
= csrf_meta_tags
= stylesheet_link_tag 'application/application', 'data-turbolinks-track' => 'reload'
= javascript_include_tag 'application/application', 'data-turbolinks-track' => 'reload'
%link{:href => asset_path('favicon.png'), :rel => 'shortcut icon'}
%body
.subPageBox{:class => @wide ? "subPageBox--wide" : ''}
= yield
================================================
FILE: app/views/messages/_deliveries.html.haml
================================================
%ul.deliveryList
- if message.queued_message && message.queued_message.locked?
%li.deliveryList__item.deliveryList__item--header
%p Message is currently being processed.
- elsif message.queued_message && message.queued_message.retry_after
%li.deliveryList__item.deliveryList__item--header
%p This message will be retried automatically in #{distance_of_time_in_words_to_now message.queued_message.retry_after}.
%p= link_to "Retry delivery now", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
- elsif message.held?
%li.deliveryList__item.deliveryList__item--header
%p
This message has been held. By releasing the message, we will allow it to continue on its way to its destination.
- if @message.hold_expiry
It will be held until #{@message.hold_expiry.to_fs(:long)}.
%p.buttonSet
= link_to "Release message", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
= link_to "Cancel hold", cancel_hold_organization_server_message_path(organization, @server, message.id), :class => "button button--small button--danger", :remote => true, :method => :post
- elsif @server.mode == 'Development'
%li.deliveryList__item.deliveryList__item--header
%p This server is in development mode so this message can be redelivered as if it had just been received.
%p= link_to "Redeliver message", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
- else
%li.deliveryList__item.deliveryList__item--header
%p This message can be redelivered as if it had just been received.
%p= link_to "Redeliver message", retry_organization_server_message_path(organization, @server, message.id), :class => "button button--small", :remote => true, :method => :post
- if message.deliveries.empty?
%li.deliveryList__item
.noData.noData--clean
%h2.noData__text No delivery attempts yet.
- else
- for delivery in message.deliveries.reverse
%li.deliveryList__item
.deliveryList__top
.deliveryList__time
= delivery.timestamp.to_fs(:long)
.deliveryList__status
- if delivery.sent_with_ssl
= image_tag 'icons/lock.svg', :class => 'deliveryList__secure'
%span.label.label--large{:class => "label--messageStatus-#{delivery.status.underscore}"}= delivery.status.underscore.humanize
- if delivery.details
%p.deliveryList__error= format_delivery_details(@server, delivery.details)
- if delivery.log_id || delivery.output
= link_to "Show technical details", '#', :class => 'js-toggle js-tech-link deliveryList__techLink', :data => {:element => '.js-tech-link, .js-tech-output'}
.deliveryList__error.deliveryList__error--output.js-tech-output.is-hidden
%p.deliveryList__error--output-text= delivery.output
- if delivery.time
%p.deliveryList__error--output-ref Time: #{delivery.time}s
- if delivery.log_id
%p.deliveryList__error--output-ref Support Ref: #{delivery.log_id}
- if message.queued_message && !message.queued_message.locked?
%p.deliveryList-removeLink= link_to "Remove from queue", remove_from_queue_organization_server_message_path(organization, @server, message.id), :method => :delete, :remote => true, :data => {:disable_with => "Removing...", :confirm => "Are you sure you wish to remove this message from the queue?"}, :class => "u-link"
================================================
FILE: app/views/messages/_header.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "Outgoing Messages", [:outgoing, organization, @server, :messages], :class => ['navBar__link', active_nav == :outgoing ? 'is-active' : '']
%li.navBar__item= link_to "Incoming Messages", [:incoming, organization, @server, :messages], :class => ['navBar__link', active_nav == :incoming ? 'is-active' : '']
%li.navBar__item= link_to "Queue", [:queue, organization, @server], :class => ['navBar__link', active_nav == :queue ? 'is-active' : '']
%li.navBar__item= link_to "Held", [:held, organization, @server, :messages], :class => ['navBar__link', active_nav == :held ? 'is-active' : '']
%li.navBar__item= link_to "Send Message", [:new, organization, @server, :message], :class => ['navBar__link', active_nav == :new ? 'is-active' : '']
%li.navBar__item= link_to "Suppressions", [:suppressions, organization, @server, :messages], :class => ['navBar__link', active_nav == :suppressions ? 'is-active' : '']
================================================
FILE: app/views/messages/_index.html.haml
================================================
.pageContent.js-ajax-region
- if @searchable
= render 'search'
- if @messages[:records].empty?
.noData.noData--clean
%h2.noData__title No messages found matching your filter.
%p.noData__text
There were no messages which matched the query that you entered. Sorry about that.
- else
= render 'list', :messages => @messages[:records]
= render 'shared/message_db_pagination', :data => @messages, :name => "message"
================================================
FILE: app/views/messages/_list.html.haml
================================================
%ul.messageList
- for message in messages
- if message.is_a?(QueuedMessage)
- queued_message = message
- message = message.message
- if message.nil? && queued_message
%li.messageList__message
.messageList__link
.messageList__details
%p.messageList__subject Deleted message ##{queued_message.message_id}
%dl.messageList__addresses
%dt Domain
%dd= queued_message.domain
%dt Locked
%dd= queued_message.locked? ? "Yes" : "No"
.messageList__meta
%p.messageList__timestamp= queued_message.created_at.in_time_zone.to_fs(:long)
%p.messageList__status
%span.label{:class => "label--messageStatus-deleted"} Deleted
- else
%li.messageList__message
= link_to organization_server_message_path(organization, @server, message.id), :class => 'messageList__link' do
.messageList__details{:class => 'messageList__details--' + message.scope}
%p.messageList__subject= message.subject || "No subject"
%dl.messageList__addresses
%dt To
%dd
- if message.rcpt_to_return_path?
%span.returnPathTag Return Path
- else
= message.rcpt_to || "none"
%dt From
%dd= message.mail_from || "none"
- if queued_message
%dt Attempts
%dd= queued_message.attempts
%dt Retry after
%dd= queued_message.retry_after&.to_fs(:short) || "ASAP"
.messageList__meta
%p.messageList__timestamp= message.timestamp.in_time_zone.to_fs(:long)
%p.messageList__status
- if message.read?
%span.label.label--purple Opened
%span.label{:class => "label--messageStatus-#{message.status.underscore}"}= message.status.underscore.humanize
================================================
FILE: app/views/messages/_message_header.html.haml
================================================
.messageHeader
.messageHeader__header{:class => "messageHeader__header--#{@message.scope}"}
%p.messageHeader__status
%span.label{:class => "label--messageStatus-#{@message.status.underscore}"}= @message.status.underscore.humanize
%h2.messageHeader__subject
= @message.subject || "No subject"
.messageHeader__basicProperties
%dl
%dt From
%dd
- if @message.mail_from
= link_to @message.mail_from || "[blank]", send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "from: #{@message.mail_from}"), :class => 'u-link'
- else
None
%dl
%dt To
%dd
- if @message.rcpt_to_return_path?
%span.returnPathTag.returnPathTag--inMessageHeader= link_to "Return Path", send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "to: #{@message.rcpt_to}"), :class => 'u-link'
- else
= link_to @message.rcpt_to || "[blank]", send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "to: #{@message.rcpt_to}"), :class => 'u-link'
%dl
%dt Received
%dd= @message.timestamp.in_time_zone.to_fs(:long)
.navBar.navBar--tertiary
%ul
%li.navBar__item= link_to "Properties", organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :properties ? 'is-active' : '']
%li.navBar__item= link_to "Activity", activity_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :activity ? 'is-active' : '']
%li.navBar__item= link_to "Headers", headers_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :headers ? 'is-active' : '']
%li.navBar__item= link_to "Spam Checks", spam_checks_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :spam_checks ? 'is-active' : '']
%li.navBar__item= link_to "Plain Text", plain_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :plain ? 'is-active' : '']
%li.navBar__item= link_to "HTML", html_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :html ? 'is-active' : '']
%li.navBar__item= link_to "Attachments", attachments_organization_server_message_path(organization, @server, @message.id), :class => ['navBar__link', active_nav == :attachments ? 'is-active' : '']
- if @message.raw_message?
%li.navBar__item= link_to "Download", download_organization_server_message_path(organization, @server, @message.id), :data => {:turbolinks => 'false'}, :class =>'navBar__link'
================================================
FILE: app/views/messages/_search.html.haml
================================================
= form_tag request.fullpath, :method => :get, :remote => true, :class => 'messageSearch', :enforce_utf8 => false do
%p
= link_to "Need help with filtering?", '#', :class => 'messageSearch__help js-toggle-helpbox'
= text_field_tag 'query', @query, :class => 'messageSearch__input js-focus-on-f js-form-submit', :placeholder => "Filter messages...", :data => {:disable_with => 'Searching...'}
.messageSearch__helpBox.is-hidden.js-helpbox
.messageSearch__left
%h3.messageSearch__helpBoxTitle
Filtering your messages
%p.messageSearch__helpBoxText
You can filter your messages on a number of attributes. At present, it is not possible to
search the content of your messages. To filter though, you can insert any of the strings
as shown opposite into the box above and press enter.
.messageSearch__right
%dl.messageSearch__definition
%dt to: rachel@example.com
%dd Returns all mail addressed to the address provided.
%dl.messageSearch__definition
%dt from: tom@example.com
%dd Returns all mail sent from to the address provided.
%dl.messageSearch__definition
%dt status: pending
%dd Returns all messages with the status provided. The suitable statuses are: pending, sent, held, softfail, hardfail and bounced.
%dl.messageSearch__definition
%dt before: yyyy-mm-dd hh:mm
%dd Returns any message received before the given timestamp.
%dl.messageSearch__definition
%dt after: yyyy-mm-dd hh:mm
%dd Returns any message received after the given timestamp.
%dl.messageSearch__definition
%dt msgid: 57f3a85b35545@server01.mail
%dd Returns any message with the given Message-ID header.
%dl.messageSearch__definition
%dt tag: password-reset
%dd Returns any message tagged with the tag provided.
%dl.messageSearch__definition
%dt spam: yes
%dd By default, spam is not shown in results. To show spam instead of non-spam, just add this to the query.
%dl.messageSearch__definition
%dt order: oldest-first
%dd By default, newest messages are shown first. To show oldest messages first, you can add this.
================================================
FILE: app/views/messages/activity.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
- page_title << "Activity"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :activity
.pageContent.pageContent--compact
%ul.messageActivity
- for entry in @entries.reverse
- if entry.is_a?(Postal::MessageDB::Delivery)
%li.messageActivity__event
%p.messageActivity__timestamp= entry.timestamp.to_fs(:long)
.messageActivity__details.messageActivity--detailsDelivery
%p.messageActivity__subject
=# entry.status.underscore.humanize
%span.label.label--large{:class => "label--messageStatus-#{entry.status.underscore}"}= entry.status.underscore.humanize
%p.messageActivity__extra= entry.details
- elsif entry.is_a?(Postal::MessageDB::Click)
%li.messageActivity__event
%p.messageActivity__timestamp= entry.timestamp.to_fs(:long)
.messageActivity__details.messageActivity--detailsClick
%p.messageActivity__subject Click for #{entry.url}
%p.messageActivity__extra Clicked from #{entry.ip_address} (#{entry.user_agent})
- elsif entry.is_a?(Postal::MessageDB::Load)
%li.messageActivity__event
%p.messageActivity__timestamp= entry.timestamp.to_fs(:long)
.messageActivity__details.messageActivity--detailsLoad
%p.messageActivity__subject Message Viewed
%p.messageActivity__extra Opened from #{entry.ip_address} (#{entry.user_agent})
%li.messageActivity__event
%p.messageActivity__timestamp= @message.timestamp.to_fs(:long)
.messageActivity__details
%p.messageActivity__subject
Message received by Postal
%p.messageActivity__extra
- if @message.credential
Received using the #{@message.credential.name} #{@message.credential.type} credential.
- if @message.received_with_ssl
Connection secured with SSL.
================================================
FILE: app/views/messages/attachments.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
- page_title << "Attachments"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :attachments
.pageContent.pageContent--compact
- if @message.attachments.empty?
.noData.noData--clean
%h2.noData__title There are no attachments for this message.
%p.noData__text
This means that we no longer store the raw data for this e-mail
or the e-mail just didn't have any attached files.
- else
%ul.largeList
- @message.attachments.each_with_index do |attachment, i|
%li.largeList__item
= link_to attachment_organization_server_message_path(organization, @server, @message.id, :attachment => i), :class => 'largeList__link', :data => {:turbolinks => "false"} do
%p.largeList__rightLabel= number_to_human_size attachment.body.to_s.bytesize
%p= attachment.filename
%p.largeList__subText= attachment.mime_type
================================================
FILE: app/views/messages/headers.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
- page_title << "Headers"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :headers
- if @message.headers.empty?
.pageContent.pageContent--compact
.noData.noData--clean
%h2.noData__title There are no headers for this message.
%p.noData__text
This means that we no longer store the raw data for this e-mail.
- else
.pageContent
.headersList
- for key, values in @message.headers
- for value in values
%dl.headersList__item
%dt= key
%dd= value
================================================
FILE: app/views/messages/held.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Held"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => :held
- if @messages.empty? && !@queried
.pageContent--compact
.noData.noData--clean
%h2.noData__title You haven't got any held messages.
%p.noData__text
You haven't sent any messages through this mail server yet. Not to worry though
they'll start appearing here as soon as you start sending them.
- else
= render 'index'
================================================
FILE: app/views/messages/html.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
- page_title << "HTML"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :html
- if @message.html_body.blank?
.pageContent.pageContent--compact
.noData.noData--clean
%h2.noData__title There's no HTML body for this message.
%p.noData__text
This means that we no longer store the raw data for this e-mail
or the e-mail didn't include a HTML part.
- else
%iframe{:width => "100%", :height => "100%", :src => html_raw_organization_server_message_path(organization, @server, @message.id)}
================================================
FILE: app/views/messages/incoming.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Incoming"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => :incoming
- if @messages[:records].empty? && !@queried
.pageContent--compact
.noData.noData--clean
%h2.noData__title No messages have been received yet.
%p.noData__text
You haven't received any messages through this mail server yet. Not to worry though
they'll start appearing here as soon as you start receiving them.
%p.noData__button
= link_to "View spam messages", incoming_organization_server_messages_path(organization, @server, :query => "spam: yes"), :class => "button button--neutral"
- else
= render 'index'
================================================
FILE: app/views/messages/new.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Send"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => :new
.pageContent.pageContent--compact
%p.pageContent__intro.u-margin
You can use this form to send a message through this mail server. This is useful
for testing and debugging purposes.
- if @message.is_a?(OutgoingMessagePrototype)
%p.pageContent__text.u-margin.newMessageType.newMessageType--outgoing
You are sending an outgoing message. This e-mail will be routed as if it was an e-mail sent from your mail server.
= link_to "Simulate an incoming e-mail instead?", {:direction => 'incoming'}, :class => 'u-link'
- else
%p.pageContent__text.u-margin.newMessageType.newMessageType--incoming
You are sending an incoming message. This e-mail will can only be sent to your routes and will behave as if it was received by your mail server.
= link_to "Simulate an outgoing e-mail instead?", {:direction => 'outgoing'}, :class => 'u-link'
= form_tag [organization, @server, :messages], :remote => true do
= hidden_field_tag 'direction', params[:direction]
.fieldSet
- if @message.is_a?(OutgoingMessagePrototype)
.fieldSet__field
= label_tag :message_from, "From ", :class => 'fieldSet__label'
.fieldSet__input
= text_field_tag "message[from]", @message.from, :autofocus => true, :class => 'input input--text'
%p.fieldSet__text
Enter the address that you wish to wish to send the message from. This must be
an address which exists at one of your verified domains.
.fieldSet__field
= label_tag :message_to, "To", :class => 'fieldSet__label'
.fieldSet__input= text_field_tag "message[to]", @message.to, :class => 'input input--text'
- else
.fieldSet__field
= label_tag :message_route_id, "Route", :class => 'fieldSet__label'
.fieldSet__input= text_field_tag "message[to]", @message.to, :class => 'input input--text'
.fieldSet__field
= label_tag :message_from, "From", :class => 'fieldSet__label'
.fieldSet__input= text_field_tag "message[from]", @message.from, :class => 'input input--text'
.fieldSet__field
= label_tag :message_subject, "Subject", :class => 'fieldSet__label'
.fieldSet__input= text_field_tag "message[subject]", @message.subject, :class => 'input input--text'
.fieldSet__field
= label_tag :message_plain_body, "Body", :class => 'fieldSet__label'
.fieldSet__input= text_area_tag "message[plain_body]", @message.plain_body, :class => 'input input--area'
.fieldSetSubmit.buttonSet
= submit_tag "Send Message", :class => 'button button--positive js-form-submit'
================================================
FILE: app/views/messages/outgoing.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Outgoing"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => :outgoing
- if @messages[:records].empty? && !@queried
.pageContent--compact
.noData.noData--clean
%h2.noData__title No messages have been sent yet.
%p.noData__text
You haven't sent any messages through this mail server yet. Not to worry though
they'll start appearing here as soon as you start sending them.
- else
= render 'index'
================================================
FILE: app/views/messages/plain.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
- page_title << "Plain Text"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :plain
.pageContent.pageContent--compact
- if @message.plain_body.blank?
.noData.noData--clean
%h2.noData__title There's no plain text body for this message.
%p.noData__text
This means that we no longer store the raw data for this e-mail
or the e-mail didn't include a plain text part.
- else
%pre.codeBlock= @message.plain_body
================================================
FILE: app/views/messages/show.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :properties
.pageContent
.messagePropertiesPage
.messagePropertiesPage__left
.messagePropertiesPage__propertyPair
%dl.messagePropertiesPage__property
%dt Spam Status
%dd
= link_to spam_checks_organization_server_message_path(organization, @server, @message.id) do
%span.label.label--large{:class => "label--spamStatus-#{@message.spam_status.underscore}"}= @message.spam_status.underscore.humanize
%dl.messagePropertiesPage__property
%dt Tag
%dd= @message.tag ? link_to(@message.tag, send("#{@message.scope}_organization_server_messages_path", organization, @server, :query => "tag: #{@message.tag}"), :class => "u-link") : "Not tagged"
.messagePropertiesPage__propertyPair
%dl.messagePropertiesPage__property
%dt Raw Message
%dd= @message.raw_message? ? "Available" : "Removed"
%dl.messagePropertiesPage__property
%dt Message Size
%dd= @message.size ? number_to_human_size(@message.size) : "n/a"
.messagePropertiesPage__propertyPair
- if @message.scope == 'incoming'
%dl.messagePropertiesPage__property
%dt Route
%dd
- if @message.route
= link_to @message.route.name, [:edit, organization, @server, @message.route], :class => "u-link"
- else
Unknown Route
%dl.messagePropertiesPage__property
%dt Domain
%dd
- if @message.domain
= link_to @message.domain.name, [organization, @server, :domains], :class => "u-link"
- else
Unknown Domain
- else
%dl.messagePropertiesPage__property
%dt Credential
%dd
- if @message.credential
= link_to @message.credential.name, [:edit, organization, @server, @message.credential], :class => "u-link"
- else
Unknown Credential
%dl.messagePropertiesPage__property
%dt Domain
%dd
- if @message.domain
= link_to @message.domain.name, [organization, @server, :domains], :class => "u-link"
- else
Unknown Domain
- if @message.threat
%dl.messagePropertiesPage__property
%dt Threat
%dd= @message.threat_details
%dl.messagePropertiesPage__property
%dt Message ID
%dd= @message.message_id || "No message ID"
- unless @message.received_with_ssl.nil?
%dl.messagePropertiesPage__property
%dt Transport Security
- if @message.received_with_ssl
%dd.messagePropertiesPage__property--locked Received over an SSL connection
- else
%dd Not received with SSL
.messagePropertiesPage__right
= render 'deliveries', :message => @message
================================================
FILE: app/views/messages/spam_checks.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Message ##{@message.id}"
- page_title << "Spam checks"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => @message.scope.to_sym
= render 'message_header', :active_nav => :spam_checks
.pageContent.pageContent--compact
- if @spam_checks.empty?
.noData.noData--clean
%h2.noData__title This message doesn't have any spam checks.
%p.noData__text
This likely means we haven't scanned this message to determine its likelyhood
of being spam. It may take a few seconds to appear after a new message is
received.
- else
%ul.spamCheckList
%li.spamCheckList__item.spamCheckList__item--total
%p.spamCheckList__score{:class => @message.spam_score <= 0 ? (@message.spam_score == 0 ? 'spamCheckList__score--neutral' : 'spamCheckList__score--positive') : 'spamCheckList__score--negative'}= @message.spam_score
.spamCheckList__details.spamCheckList__details--total
Total spam score for e-mail
- for spam_check in @spam_checks
%li.spamCheckList__item
%p.spamCheckList__score{:class => spam_check['score'] <= 0 ? (spam_check['score'] == 0 ? 'spamCheckList__score--neutral' : 'spamCheckList__score--positive') : 'spamCheckList__score--negative'}= spam_check['score']
.spamCheckList__details
%p.spamCheckList__code= spam_check['code']
%p.spamCheckList__description= spam_check['description']
================================================
FILE: app/views/messages/suppressions.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Suppression List"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :messages
= render 'header', :active_nav => :suppressions
.pageContent.pageContent--compact
- if @suppressions[:records].empty?
.noData.noData--clean
%h2.noData__title No addresses on the suppression list.
%p.noData__text
When messages cannot be delivered, addresses are added to the suppression list which stops
future messages to the same recipient being sent through.
- else
%p.pageContent__intro.u-margin
When messages cannot be delivered, addresses are added to the suppression list which stops
future messages to the same recipient being sent through. Recipients are removed from the list after #{Postal::Config.postal.default_suppression_list_automatic_removal_days} days.
%ul.suppressionList
- for suppression in @suppressions[:records]
%li.suppressionList__item
.suppressionList__left
%p.suppressionList__address= link_to suppression['address'], outgoing_organization_server_messages_path(organization, @server, :query => "to: #{suppression['address']}")
%p.suppressionList__reason= suppression['reason'].capitalize
.suppressionList__right
%p.suppressionList__timestamp Added #{Time.zone.at(suppression['timestamp']).to_fs(:long)}
%p.suppressionList__timestamp
Expires #{Time.zone.at(suppression['keep_until']).to_fs(:long)}
- if suppression['keep_until'] < Time.now.to_f
%span.u-red expired
= render 'shared/message_db_pagination', :data => @suppressions, :name => "suppression"
================================================
FILE: app/views/organization_ip_pools/_nav.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "IP Pools", organization_ip_pools_path(organization), :class => ['navBar__link', active_nav == :ips ? 'is-active' : '']
%li.navBar__item= link_to "Rules", organization_ip_pool_rules_path(organization), :class => ['navBar__link', active_nav == :rules ? 'is-active' : '']
================================================
FILE: app/views/organization_ip_pools/index.html.haml
================================================
- page_title << "IPs"
- page_title << "Rules"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
Dedicated IPs
= render 'organizations/nav', :active_nav => :ips
= render 'nav', :active_nav => :ips
.pageContent.pageContent--compact
- if current_user.admin?
%p.pageContent__intro.u-margin
Choose which IP pools this organization will have access to send mail using. Organization
users will be able to choose from any of the pools chosen below. Admins can override on a per
server basis if required.
= form_tag [:assignments, @organization, :ip_pools], :method => :put do
%ul.checkboxList.u-margin
- for ip_pool in IPPool.order(:name)
%li.checkboxList__item
.checkboxList__checkbox= check_box_tag "ip_pools[]", ip_pool.id, @organization.ip_pools.include?(ip_pool), :id => "ip_pool_#{ip_pool.id}"
.checkboxList__label
= label_tag "ip_pool_#{ip_pool.id}", ip_pool.name, :class => 'checkboxList__actualLabel'
%p= submit_tag "Save IP pool assignment", :class => 'button button--positive'
- else
- if @ip_pools.empty?
.noData.noData--clean
- if @server.nil?
%h2.noData__title You don't have any assigned IP addresses.
%p.noData__text
Once you've been assigned IP addresses they will appear here. You can then use them in rules and
for servers.
- else
.ipList
- for ip_pool in @ip_pools
.ipList__item
%p.ipList__name= ip_pool.name
%ul.ipList__addressList
%li.ipList__address.ipList__address--header
%p.ipList__ipv4 IPv4 Address
%p.ipList__ipv6 IPv6 Address
%p.ipList__hostname Hostname
- for address in ip_pool.ip_addresses
%li.ipList__address
%p.ipList__ipv4= address.ipv4
%p.ipList__ipv6= address.ipv6
%p.ipList__hostname= address.hostname
================================================
FILE: app/views/organizations/_nav.html.haml
================================================
.navBar
%ul
%li.navBar__item= link_to "Mail Servers", organization_root_path(organization), :class => ['navBar__link', active_nav == :servers ? 'is-active' : '']
%li.navBar__item= link_to "Domains", organization_domains_path(organization), :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']
%li.navBar__item= link_to "Settings", organization_settings_path(organization), :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']
- if Postal.ip_pools?
%li.navBar__item= link_to "IPs", organization_ip_pools_path(organization), :class => ['navBar__link', active_nav == :ips ? 'is-active' : '']
- if current_user.admin?
%li.navBar__item= link_to "Delete Organization", organization_delete_path(organization), :class => ['navBar__link', active_nav == :delete ? 'is-active' : '']
================================================
FILE: app/views/organizations/delete.html.haml
================================================
- page_title << organization.name
- page_title << "Delete"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= organization.name
→
Delete organization
= render 'nav', :active_nav => :delete
.pageContent.pageContent--compact
%h2.pageContent__intro.u-margin
If you no longer need this organization you can delete it. When you delete an organization
all its mail servers & data will be deleted from our systems.
.dangerZone
%p.pageContent__text.u-margin
To continue to delete this organization, please enter the name of the organization in the field below and press
continue. There will be no other confirmations.
= form_tag [organization, :delete], :method => :delete, :remote => true do
= hidden_field_tag 'return_to', params[:return_to]
%p.u-margin
= text_field_tag "confirm_text", '', :class => 'input input--text input--danger'
.buttonSet.u-center
= submit_tag "Delete this organization, mail servers and all messages", :class => 'button button--danger js-form-submit'
================================================
FILE: app/views/organizations/edit.html.haml
================================================
- page_title << @organization.name
- page_title << "Organization Settings"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
Settings
= render 'nav', :active_nav => :settings
.pageContent.pageContent--compact
= form_for @organization_obj, :url => organization_settings_path(@organization_obj), :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
.fieldSet__field
= f.label :time_zone, :class => 'fieldSet__label'
.fieldSet__input
= f.time_zone_select :time_zone, [], {}, :class => 'input input--select'
%p.fieldSet__text
Choose the time zone that your organization exists within. This is used when displaying times in places
where there isn't a logged in user to provide their own time zone.
%p.fieldSetSubmit.buttonSet
= f.submit "Save Settings", :class => 'button button--positive js-form-submit'
================================================
FILE: app/views/organizations/index.html.haml
================================================
- page_title << "Welcome"
.pageHeader
%h1.pageHeader__title Welcome to Postal, #{current_user.first_name}
.pageContent.pageContent--compact
- if @organizations.empty?
.noData.noData--clean
%p.noData__title There are no organizations.
- if current_user.admin?
%p.noData__text
You need an organization otherwise you can't do much here. Hit
the button below to create the first organization.
%p.noData__button= link_to "Create the first organization", :new_organization, :class => 'button button--positive'
- else
%p.noData__text
You don't have access to any organizations yet. Ask your administrator to invite
you to some organizations.
- else
%p.pageContent__intro.u-margin
Organizations are entities which are able to deploy mail servers.
Choose an existing organization from the list opposite or use the button below
to create a new one.
%ul.largeList.u-margin
- for organization in @organizations
%li.largeList__item
= link_to organization_root_path(organization), :class => 'largeList__link' do
= organization.name
- if current_user.admin?
%p.u-center= link_to "Start another organization", :new_organization, :class => 'button button--positive'
================================================
FILE: app/views/organizations/new.html.haml
================================================
- page_title << "Create a new organization"
.pageHeader
%h1.pageHeader__title Create a new organization
.pageContent.pageContent--compact
%p.pageContent__intro.u-margin
If you're starting a new organization you can do so by completing this form. You'll be able
to invite new users & create mail servers as soon as it has been created.
= form_for @organization, :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
.fieldSet__field
= f.label :permalink, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :permalink, :class => 'input input--text', :placeholder => "Automatically generated"
%p.fieldSet__text
This is a short name which is used in usernames and the API to identify your organization.
It should only contain letters, numbers & hyphens.
.fieldSetSubmit.buttonSet
= f.submit "Create organization", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
= link_to "Back to homepage", root_path, :class => 'button button--neutral'
================================================
FILE: app/views/routes/_form.html.haml
================================================
= form_for [organization, @server, @route], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input
.routeNameInput
= f.text_field :name, :autofocus => true, :class => 'input input--text routeNameInput__name'
%span.routeNameInput__at @
= f.select :domain_id, domain_options_for_select(@server, @route.domain), {}, :class => 'input input--select routeNameInput__domain'
%p.fieldSet__text
Enter the address you wish to route. In addition to the name you enter, you'll also received "tagged" mail for this
address. See our documentation for details about tagged mail.
.fieldSet__field
= f.label :_endpoint, :class => 'fieldSet__label'
.fieldSet__input
= f.select :_endpoint, endpoint_options_for_select(@server, @route._endpoint), {}, :class => 'input input--select'
%p.fieldSet__text
This is the endpoint where mail to this address will be delivered to. If you need to add different endpoints,
you can do this using the links above this form.
.fieldSet__field
= f.label :_endpoint, "Additional Endpoints", :class => 'fieldSet__label'
.fieldSet__input
.fieldSet__selectList
- for endpoint in @route.additional_route_endpoints_array
= select_tag "route[additional_route_endpoints_array][]", endpoint_options_for_select(@server, endpoint, :other => false), :class => 'input input--select'
= select_tag "route[additional_route_endpoints_array][]", endpoint_options_for_select(@server, nil, :other => false), :class => 'input input--select'
%p.fieldSet__text
If you wish to deliver a message to multiple endpoints, you can do so by choosing them from the list above.
.fieldSet__field
= f.label :spam_mode, :class => 'fieldSet__label'
.fieldSet__input
= f.select :spam_mode, Route::SPAM_MODES, {}, :class => 'input input--select'
%p.fieldSet__text
You can choose what should happen to mail which we identify as spam. If you choose Mark we'll tell you
we think its spam when we deliver it to your endpoint. If you choose Quarantine , we won't send the message
to you at all and you'll have manually accept it through our web interface or the API if you want it delivered.
If you choose Fail , the message will simply be failed without any attempt to deliver your message.
- if @route.persisted?
.fieldSet__field
= f.label :forward_address, "Address", :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :forward_address, :class => 'input input--text', :readonly => true
%p.fieldSet__text
If you don't wish to point your MX records to our server, you can redirect your mail to this address and
will be routed to your endpoint as if it was sent to the address you entered above.
.fieldSetSubmit.buttonSet
= f.submit @route.new_record? ? "Create route" : "Save route", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete route", [organization, @server, @route], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this route?"}
================================================
FILE: app/views/routes/_header.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "Routes", [organization, @server, :routes], :class => ['navBar__link', active_nav == :routes ? 'is-active' : '']
%li.navBar__item= link_to "HTTP Endpoints", [organization, @server, :http_endpoints], :class => ['navBar__link', active_nav == :http_endpoints ? 'is-active' : '']
%li.navBar__item= link_to "SMTP Endpoints", [organization, @server, :smtp_endpoints], :class => ['navBar__link', active_nav == :smtp_endpoints ? 'is-active' : '']
%li.navBar__item= link_to "Address Endpoints", [organization, @server, :address_endpoints], :class => ['navBar__link', active_nav == :address_endpoints ? 'is-active' : '']
================================================
FILE: app/views/routes/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Routes"
- page_title << "Edit Route"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'header', :active_nav => :routes
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/routes/index.html.haml
================================================
- page_title << @server.name
- page_title << "Routes"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'header', :active_nav => :routes
.pageContent.pageContent--compact
- if @routes.empty?
.noData.noData--clean
%h2.noData__title No routes have been configured for this server.
%p.noData__text
To receive incoming mail, you need to add routes to where we should send
messages we receive for your domain. You can send incoming e-mail to
HTTP endpoints, other SMTP servers or e-mail addresses.
- if @server.smtp_endpoints.empty? && @server.http_endpoints.empty? && @server.address_endpoints.empty?
%p.noData__button.buttonSet.buttonSet--center
= link_to "Add a HTTP endpoint", new_organization_server_http_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => "You can now go ahead and add your first route for this HTTP endpoint"), :class => 'button button--positive'
= link_to "Add a SMTP endpoint", new_organization_server_smtp_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => "You can now go ahead and add your first route for this SMTP endpoint"), :class => 'button button--positive'
= link_to "Add an address endpoint", new_organization_server_address_endpoint_path(organization, @server, :return_to => new_organization_server_route_path(organization, @server), :return_notice => "You can now go ahead and add your first route for this address endpoint"), :class => 'button button--positive'
%p.noData__postButtonText
Once you've added these, you'll be able to come back here to route a
specific e-mail address to your newly created endpoint. You can
#{link_to "add a route without an endpoint", new_organization_server_route_path(organization, @server), :class => "u-link"} if you really want.
- else
%p.noData__button
= link_to "Add your first route", [:new, organization, @server, :route], :class => 'button button--positive'
- else
%p.pageContent__intro.u-margin
Routes control where incoming mail for your domain is sent. Messages can be sent to
HTTP endpoints, other SMTP servers or e-mail addresses.
%p.u-margin.pageContent__helpLink= link_to "Read more about receiving e-mails", [organization, @server, :help_incoming]
%ul.routeList.u-margin
- for route in @routes
%li.routeList__item
= link_to [:edit, organization, @server, route], :class => 'routeList__link' do
%p.routeList__name= route.description
.routeList__details
%p.routeList__endpoint{:class => "routeList__endpoint--#{route.endpoint_type&.underscore || 'none'}"}
- if route.mode == 'Endpoint'
= route.endpoint.description
- else
= t("route_modes.#{route.mode.underscore}")
%p.routeList__spamMode= t("route_spam_modes.#{route.spam_mode.underscore}")
%p.u-center= link_to "Add another route", [:new, organization, @server, :route], :class => 'button button--positive'
================================================
FILE: app/views/routes/new.html.haml
================================================
- page_title << @server.name
- page_title << "Routes"
- page_title << "Add Route"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'header', :active_nav => :routes
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/servers/_form.html.haml
================================================
= form_for [organization, @server], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
.fieldSet__field
= f.label :permalink, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :permalink, :class => 'input input--text', :placeholder => "Automatically generated", :disabled => @server.persisted?
%p.fieldSet__text
This is a short name which is used in usernames and the API to identify your organization.
It should only contain letters, numbers & hyphens.
.fieldSet__field
= f.label :mode, :class => 'fieldSet__label'
.fieldSet__input
= f.select :mode, Server::MODES, {}, :autofocus => true, :class => 'input input--select'
%p.fieldSet__text
The mode you choose will determine how messages are handled. When in Live mode, all
e-mail will be routed normally to the intended recipients. When in Development mode,
outgoing & incoming mail will be held and only visible in the web interface and will not be
sent to any recipients or HTTP endpoints.
- if Postal.ip_pools?
.fieldSet__field
= f.label :ip_pool_id, :class => 'fieldSet__label'
.fieldSet__input
= f.collection_select :ip_pool_id, organization.ip_pools.includes(:ip_addresses).order("`default` desc, name asc"), :id, :name, {}, :class => 'input input--select'
%p.fieldSet__text
This is the set of IP addresses which outbound e-mails will be delivered from.
- if @server.persisted?
.fieldSet__field
= f.label :allow_sender, "Send as any", :class => 'fieldSet__label'
.fieldSet__input
.input.is-disabled= @server.allow_sender? ? "Enabled" : "Disabled"
%p.fieldSet__text
When enabled, you will be able to use any e-mail address in the From header on outgoing e-mails.
You will need to add a Sender header which must be an address at one of your verified domains.
.fieldSet__field
= f.label :postmaster_address, "Postmaster", :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :postmaster_address, :class => 'input input--text', :placeholder => "Set based on the domain"
%p.fieldSet__text
This is the e-mail address that is included in any bounce messages that are sent when incoming
messages cannot be delivered. By default, the address is postmaster@[yourdomain.com].
.fieldSetSubmit.buttonSet
= f.submit f.object.new_record? ? "Build server" : "Save server", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- unless f.object.persisted?
= link_to "Back to server list", organization_root_path(organization), :class => 'button button--neutral'
================================================
FILE: app/views/servers/_header.html.haml
================================================
.serverHeader
.serverHeader__stripe{:class => "serverHeader__stripe--#{@server.status.underscore}"}= @server.status
.serverHeader__info
%p.serverHeader__title= @server.name
%ul.serverHeader__list
- total, unverified, bad_dns = @server.domain_stats
- if total == 0
%li No domains have been added for this server
- elsif bad_dns == 0
%li.serverHeader__list--ok DKIM & SPF configured correctly on #{pluralize total - unverified, 'domain'}
- else
%li.serverHeader__list--warning= link_to "#{pluralize bad_dns, 'domain'} has misconfigured DNS records", [organization, @server, :domains]
- if unverified > 0
%li= link_to "#{pluralize unverified, 'domain'} is awaiting verification", [organization, @server, :domains]
- if Postal.ip_pools? && @server.ip_pool
%li Sending via #{@server.ip_pool.name}
.serverHeader__stats{"data-turbolinks-permanent" => true, :id => "serverStats-#{@server.uuid}"}
%ul.serverHeader__statsList
%li.serverHeader__stat-held
= link_to "#{pluralize @server.held_messages, 'message'} held", held_organization_server_messages_path(organization, @server), :class => 'js-held-count'
%li.serverHeader__stat-queue
= link_to pluralize(@server.queue_size, 'queued message'), queue_organization_server_path(organization, @server), :class => "js-queue-size"
%li.serverHeader__stat-bounces
= link_to "#{number_to_percentage @server.bounce_rate, :precision => 1} bounce rate", outgoing_organization_server_messages_path(organization, @server, :query => "status: hardfail status:bounced"), :class => 'js-bounce-rate'
%li.serverHeader__stat-size
= link_to "#{number_to_human_size @server.message_db.total_size} used", [:retention, organization, @server], :class => 'js-disk-size'
.serverHeader__usage{"data-turbolinks-permanent" => true, :id => "serverUsage-#{@server.uuid}"}
%p.serverHeader__usageTitle Message throughput — last 60 minutes
.serverHeader__usageLine
.serverHeader__usageLineLabel Outgoing messages
.serverHeader__usageLineBar
.bar
.bar__inner.js-outgoing-bar{:style => style_width(@server.throughput_stats[:outgoing_usage], :color => true)}
.serverHeader__usageLineValue.js-outgoing-count{:title => "Limit: #{@server.send_limit || '∞'} every 60 minutes"}
= number_with_delimiter @server.throughput_stats[:outgoing]
.serverHeader__usageLine
.serverHeader__usageLineLabel Incoming messages
.serverHeader__usageLineValue.js-incoming-count
= number_with_delimiter @server.throughput_stats[:incoming]
.serverHeader__usageLine
.serverHeader__usageLineLabel Message Rate
.serverHeader__usageLineValueLarge
%b.js-message-rate= number_with_precision @server.message_rate, :precision => 2
messages/minute
.navBar
%ul
%li.navBar__item= link_to "Overview", [organization, @server], :class => ['navBar__link', active_nav == :overview ? 'is-active' : '']
%li.navBar__item= link_to "Messages", [:outgoing, organization, @server, :messages], :class => ['navBar__link', active_nav == :messages ? 'is-active' : '']
%li.navBar__item= link_to "Domains", [organization, @server, :domains], :class => ['navBar__link', active_nav == :domains ? 'is-active' : '']
%li.navBar__item= link_to "Routing", [organization, @server, :routes], :class => ['navBar__link', active_nav == :routing ? 'is-active' : '']
%li.navBar__item= link_to "Credentials", [organization, @server, :credentials], :class => ['navBar__link', active_nav == :credentials ? 'is-active' : '']
%li.navBar__item= link_to "Webhooks", [organization, @server, :webhooks], :class => ['navBar__link', active_nav == :webhooks ? 'is-active' : '']
%li.navBar__item= link_to "Settings", [:edit, organization, @server], :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']
%li.navBar__item.navBar__item--end= link_to "Help", [organization, @server, :help_outgoing], :class => ['navBar__link', active_nav == :help ? 'is-active' : '']
================================================
FILE: app/views/servers/_settings_header.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "Server Settings", [:edit, organization, @server], :class => ['navBar__link', active_nav == :settings ? 'is-active' : '']
%li.navBar__item= link_to "Spam", [:spam, organization, @server], :class => ['navBar__link', active_nav == :spam ? 'is-active' : '']
%li.navBar__item= link_to "Retention", [:retention, organization, @server], :class => ['navBar__link', active_nav == :retention ? 'is-active' : '']
%li.navBar__item= link_to "Send Limit", [:limits, organization, @server], :class => ['navBar__link', active_nav == :limits ? 'is-active' : '']
- if Postal.ip_pools?
%li.navBar__item= link_to "IP Rules", [organization, @server, :ip_pool_rules], :class => ['navBar__link', active_nav == :ip_pool_rules ? 'is-active' : '']
- if current_user.admin?
%li.navBar__item= link_to "Advanced Settings", [:advanced, organization, @server], :class => ['navBar__link', active_nav == :admin ? 'is-active' : '']
%li.navBar__item= link_to "Delete", [:delete, organization, @server], :class => ['navBar__link', active_nav == :delete ? 'is-active' : '']
================================================
FILE: app/views/servers/_sidebar.html.haml
================================================
- servers = organization.servers.present.order(:name).to_a
= content_for :sidebar do
.js-searchable
= form_tag '', :class => 'sidebar__search js-searchable__input' do
= text_field_tag 'query', '', :class => 'sidebar__searchInput js-focus-on-s', :placeholder => "Filter servers..."
%p.sidebar__placeholder.js-searchable__empty{:class => ("is-hidden" if servers.any?)}
No servers found.
%ul.sidebarServerList.js-searchable__list{:class => ("is-hidden" if servers.empty?)}
- for server in servers
%li.sidebarServerList__item.js-searchable__item{:data => {:url => organization_server_path(organization, server), :value => server.name.downcase.gsub(/\W/, '')}}
= link_to [organization, server], :class => ['sidebarServerList__link', (active_server == server ? 'is-active' : '')] do
%p.sidebarServerList__mode.label{:class => "label--serverStatus-#{server.status.underscore}"}= t("server_statuses.#{server.status.underscore}")
%p.sidebarServerList__title= server.name
%p.sidebarServerList__quantity #{number_with_precision server.message_rate, :precision => 2} messages/minute
%p.sidebar__new= link_to "Build a new mail server", [:new, organization, :server]
================================================
FILE: app/views/servers/advanced.html.haml
================================================
- page_title << @server.name
- page_title << "Settings"
- page_title << "Advanced"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :settings
= render 'settings_header', :active_nav => :admin
.pageContent.pageContent--compact
.u-margin
= form_for [organization, @server], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet.fieldSet--wide
.fieldSet__field
= f.label :send_limit, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :send_limit, :class => 'input input--text', :placeholder => "No limit"
%p.fieldSet__text This is the maximum number of e-mails that can be sent through this mail server in a 60 minute period.
.fieldSet__field
= f.label :allow_sender, "Allow sender header", :class => 'fieldSet__label'
.fieldSet__input
= f.select :allow_sender, [["No", false], ["Yes - can use Sender header", true]], {}, :class => 'input input--select'
%p.fieldSet__text If enabled, outgoing messages can use any address in the From header as long as a Sender header is included with an authorized address.
.fieldSet__field
= f.label :privacy_mode, "Privacy mode", :class => 'fieldSet__label'
.fieldSet__input
= f.select :privacy_mode, [["Disabled", false], ["Enabled", true]], {}, :class => 'input input--select'
%p.fieldSet__text If enabled, when Postal adds Received headers to e-mails it will not include IP or hostname information of the client submitting the message.
.fieldSet__field
= f.label :log_smtp_data, "Log SMTP data?", :class => 'fieldSet__label'
.fieldSet__input
= f.select :log_smtp_data, [["No", false], ["Yes - log all SMTP DATA (debug only)", true]], {}, :class => 'input input--select'
%p.fieldSet__text
By default, no information after the DATA command in an SMTP command is logged. If enabled, all this data will be logged too. This should only
be used for debugging.
.fieldSet__field
= f.label :outbound_spam_threshold, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :outbound_spam_threshold, :class => 'input input--text', :placeholder => "No outbound spam checking"
%p.fieldSet__text
By default, outgoing messages aren't scanned for spam. You can specify a threshold here and outgoing messages that exceed this will
not be permitted to be sent through the mail server.
.fieldSet__field
= f.label :message_retention_days, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :message_retention_days, :class => 'input input--text'
%p.fieldSet__text
The number of days that message meta data is stored in the database after it has been added.
.fieldSet__field
= f.label :raw_message_retention_days, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :raw_message_retention_days, :class => 'input input--text'
%p.fieldSet__text
The number of days that raw message data (bodies & attachments) are stored in the database after it has been added.
.fieldSet__field
= f.label :raw_message_retention_size, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :raw_message_retention_size, :class => 'input input--text'
%p.fieldSet__text
The total amount of disk space (in megabytes) to allow raw message data to use on the disk. Older messages will be deleted to keep
the total usage below this amount.
.fieldSetSubmit.fieldSetSubmit--wide.buttonSet
= f.submit "Save server", :class => 'button button--positive js-form-submit'
- if @server.suspended_at
= form_tag [:unsuspend, organization, @server], :remote => true do
.fieldSetSubmit.fieldSetSubmit--wide.buttonSet
= submit_tag "Unsuspend server", :class => 'button button--danger js-form-submit'
- else
= form_tag [:suspend, organization, @server], :remote => true do
%fieldset.fieldSet.fieldSet--wide
.fieldSet__field
= label_tag :reason, 'Suspension Reason', :class => 'fieldSet__label'
.fieldSet__input
= text_field_tag :reason, '', :class => 'input input--text', :required => true
%p.fieldSet__text
If you wish to disable this server and stop it sending messages, enter a reason above. Any users assigned to the
server will be notified of the suspension by e-mail.
.fieldSetSubmit.fieldSetSubmit--wide.buttonSet
= submit_tag "Suspend server", :class => 'button button--positive js-form-submit'
================================================
FILE: app/views/servers/delete.html.haml
================================================
- page_title << @server.name
- page_title << "Delete Server"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :settings
= render 'settings_header', :active_nav => :delete
.pageContent.pageContent--compact
%h2.pageContent__intro.u-margin
If you no longer need this server you can remove it. When you remove a server all
retained messages will be deleted and all mail which is received will be rejected
immediately.
.dangerZone
%p.pageContent__text.u-margin
To continue to remove this server, please enter the server name in the field below and press
continue. There will be no other confirmations.
= form_tag [organization, @server], :remote => true, :method => :delete do
= hidden_field_tag 'return_to', params[:return_to]
%p.u-margin
= text_field_tag "confirm_text", '', :class => 'input input--text input--danger'
.buttonSet.u-center
= submit_tag "Delete this mail server and all messages", :class => 'button button--danger'
================================================
FILE: app/views/servers/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Settings"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :settings
= render 'settings_header', :active_nav => :settings
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/servers/index.html.haml
================================================
- page_title << "Choose mail server"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
Mail Servers
= render 'organizations/nav', :active_nav => :servers
.pageContent.pageContent--compact
- if @servers.empty?
.noData.noData--clean
%p.noData__title There are no mail servers for this organization yet.
%p.noData__text
Great - you've got an organization, now you need to provision a mail server.
Once you've got a mail server, you can start sending & receiving messages.
%p.noData__button.buttonSet.buttonSet--center
= link_to "Build your first mail server", [:new, organization, :server], :class => 'button button--positive'
- else
.js-searchable
%p.messageSearch= text_field_tag 'query', params[:query], :class => 'messageSearch__input js-searchable__input js-focus-on-s', :placeholder => "Find a server..."
%ul.largeList.u-margin.js-searchable__list
- for server in @servers
%li.largeList__item.js-searchable__item{:data => {:value => server.name.downcase.gsub(/\W/, ''), :url => url_for([organization, server])}}
= link_to [organization, server], :class => 'largeList__link' do
%span.largeList__rightLabel.label{:class => "label--serverStatus-#{server.status.underscore}"}= t("server_statuses.#{server.status.underscore}")
%p= server.name
%p.largeList__subText #{number_with_precision server.message_rate, :precision => 2} messages/minute
.js-searchable__empty.is-hidden
.noData.noData--clean
%p.noData__title No servers were found...
%p.noData__text
There were no servers found matching what you've typed it.
%p.u-center= link_to "Build a new mail server", [:new, organization, :server], :class => 'button button--positive'
================================================
FILE: app/views/servers/limits.html.haml
================================================
- page_title << @server.name
- page_title << "Limits"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :settings
= render 'settings_header', :active_nav => :limits
.pageContent.pageContent--compact
%p.pageContent__intro.u-margin
In order to protect our reputation and ensure the resiliency of our service,
we implement limits on the amount of e-mail that can pass through your mail
server.
%p.pageContent__text.u-margin
The main limit to be aware of is the amount of e-mail that you can send
from your mail server to external recipients in a rolling 60 minute window.
The current limit is shown below.
%ul.limits.u-margin
%li.limits__limit
%p.limits__value
- if @server.send_limit
= number_with_delimiter @server.send_limit
- else
unlimited
%p.limits__frequency e-mails every 60 minutes*
%p.pageContent__text
You can view your current usage & limit on the top of right of every mail server
page in the web interface. The bars will show you how close you are to reaching the
limits. Although we show your incoming mail throughput, it is not limited at present.
%p.pageContent__subTitle What happens if I reach the limit?
%p.pageContent__text
If you reach your outgoing limit, any new e-mails that you try to send will be held and
will need to be released manually when your usage has dropped.
%p.pageContent__text
You will be notified by e-mail (and with a webhook if enabled) when you are approaching
and/or exceeding your limits.
================================================
FILE: app/views/servers/new.html.haml
================================================
- page_title << "Build new mail server"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= @organization.name
→
Build a new mail server
= render 'organizations/nav', :active_nav => :servers
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/servers/queue.html.haml
================================================
- page_title << @server.name
- page_title << "Messages"
- page_title << "Queue"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :messages
= render 'messages/header', :active_nav => :queue
- if @messages.empty?
.pageContent--compact
.noData.noData--clean
%h2.noData__title Your queue is currently empty.
%p.noData__text
Messages which haven't yet been delivered successfully will appear in your queue until
we've delivered them or we've given up trying.
- else
.pageContent
%p.pageContent__intro.u-margin
All messages that pass through your mail server first enter this queue. Any messages
that cannot be delivered immediately remain in the queue until they can be successfully
delivered or we give up on them.
= render 'messages/list', :messages => @messages_with_message
= paginate @messages
================================================
FILE: app/views/servers/retention.html.haml
================================================
- page_title << @server.name
- page_title << "Message Retention"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :settings
= render 'settings_header', :active_nav => :retention
.pageContent.pageContent--compact
%p.pageContent__intro.u-margin
The length of time that messages are stored by us are shown below. If you need
to store messages for longer, please contact us and we can work out a custom
plan.
.retentionLimits
%dl.retentionLimits__limit
.retentionLimits__label Number of days that raw message data will be stored
.retentionLimits__info
.retentionLimits__value
- if @server.raw_message_retention_days
= pluralize @server.raw_message_retention_days, 'day'
- else
Indefinitely
.retentionLimits__text
This is the number of whole days that raw message content will be stored by us.
Raw message is the actual content of the message including headers & attachments.
%dl.retentionLimits__limit
.retentionLimits__label Volume of raw message data that will be stored
.retentionLimits__info
.retentionLimits__value
- if @server.raw_message_retention_size
= number_to_human_size @server.raw_message_retention_size * 1024 * 1024
- else
No limit
.retentionLimits__text
This is the amount of e-mail that can be stored. When you exceed this amount, messages will be removed in
whole day increments starting with the oldest stored day.
%dl.retentionLimits__limit
.retentionLimits__label Number of days of message meta data will be available
.retentionLimits__info
.retentionLimits__value
- if @server.message_retention_days
= pluralize @server.message_retention_days, 'day'
- else
Indefinitely
.retentionLimits__text
This is the number of days of messages that will be available through the web interface.
You will be able to view basic meta information & delivery details but raw data might not
be available unless it is within the retention periods above.
================================================
FILE: app/views/servers/show.html.haml
================================================
- page_title << @server.name
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :overview
- if @messages.empty?
.pageContent--compact
.noData.noData--clean
%h2.noData__title Your new mail server is ready to go.
%p.noData__text
Check out the information below to get started sending & receiving e-mail through your new mail server.
%p.noData__button.buttonSet.buttonSet--center
= link_to "Read about sending e-mail", [organization, @server, :help_outgoing], :class => "button"
= link_to "Read about receiving e-mail", [organization, @server, :help_incoming], :class => "button"
- else
.pageContent
- if @server.suspended?
.suspensionBox.u-margin
%p
This server has been suspended and is not permitted to send or receive e-mail.
If you have any questions about this please contact our support team for assistance.
Please be aware that suspended servers will be fully deleted from our system 30 days after
suspension.
- if @server.actual_suspension_reason
%p.suspensionBox__reason
Reason: #{@server.actual_suspension_reason}
.mailGraph.u-margin{:data => {:data => @graph_data.to_json}}
%ul.mailGraph__key
%li.mailGraph__key--in Incoming Messages
%li.mailGraph__key--out Outgoing Messages
.mailGraph__graph
%ul.mailGraph__labels
- if @graph_type == :hourly
%li #{@first_date.strftime("%A at %l%P")} →
%li Today at #{Time.now.strftime("%l%P")}
- else
%li #{@first_date.to_date.to_fs(:long)} →
%li Today
.titleWithLinks.u-margin
%h2.titleWithLinks__title Recently processed e-mails
%ul.titleWithLinks__links
%li= link_to "View message queue", [:queue, organization, @server], :class => 'titleWithLinks__link'
%li= link_to "View full e-mail history", [:outgoing, organization, @server, :messages], :class => 'titleWithLinks__link'
= render 'messages/list', :messages => @messages
================================================
FILE: app/views/servers/spam.html.haml
================================================
- page_title << @server.name
- page_title << "Spam Handling"
= render 'sidebar', :active_server => @server
= render 'header', :active_nav => :settings
= render 'settings_header', :active_nav => :spam
.pageContent.pageContent--compact
%p.pageContent__intro.u-margin
Postal inspects all incoming messages for spam and other threats. Incoming messages
are assigned a score which represents how likely an e-mail is to be spam. From here
you can choose at which level you'd like to identify messages as spam.
= form_for [organization, @server], :remote => true do |f|
.u-margin
%p.pageContent__subTitle Incoming Spam Threshold
%p.pageContent__text.u-margin
The main spam threshold is what determines whether a message is spam or not. How incoming
messages that are detected as spam are processed is determined by the route which the incoming
message was sent to. You can choose between marking the message as spam and sending it on to
your endpoint, putting it into quarantine (holding it until manually released) or just failing it.
%p= f.text_field :spam_threshold, :type => :range, :class => 'spamRange', :min => -10, :max => 25, :step => 0.5, :data => {:update => "js-spam-threshold-text"}
%p.spamRangeLabel Threshold is currently #{@server.spam_threshold}
.u-margin
%p.pageContent__subTitle Incoming Spam Failure Threshold
%p.pageContent__text.u-margin
Any messages which are over your spam failure threshold will fail immediately. This is used
to catch messages that we are very sure are spam to avoid needlessly sending them around the place.
%p= f.text_field :spam_failure_threshold, :type => :range, :class => 'spamRange spamRange--hot', :min => 10, :max => 50, :step => 0.5, :data => {:update => "js-spam-failure-threshold-text"}
%p.spamRangeLabel Threshold is currently #{@server.spam_failure_threshold}
- if @server.outbound_spam_threshold
.u-margin
%p.pageContent__subTitle Outgoing Spam Threshold
%p.pageContent__text.u-margin
To prevent abuse of our services, we check outgoing messages to see whether they're likely to be
caught as spam by other providers. Messages that score higher than the threshold set by us will
not be passed through. If this limit needs adjusting, contact us for assistance.
%b The threshold for this server is currently #{@server.outbound_spam_threshold}.
%p= f.submit "Save Spam Thresholds", :class => "button button--positive js-form-submit"
================================================
FILE: app/views/sessions/begin_password_reset.html.haml
================================================
- page_title << "Reset your password"
.subPageBox__title
Reset your password
= display_flash
.subPageBox__content
%p.subPageBox__text
If you've forgotten your password, just enter your e-mail address below and we'll send you an email with a link which
will allow you to choose a new password.
= form_tag login_reset_path, :class => 'loginForm' do
= hidden_field_tag 'return_to', params[:return_to]
%p.loginForm__input= text_field_tag 'email_address', '', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => true, :tabindex => 1
.loginForm__submit
%ul.loginForm__links
%li= link_to "Back to login", login_path(:return_to => params[:return_to])
%p= submit_tag "Continue", :class => 'button button--positive', :tabindex => 3
================================================
FILE: app/views/sessions/finish_password_reset.html.haml
================================================
- page_title << "Reset your password"
.subPageBox__title
Choose a new password
= display_flash
.subPageBox__content
%p.subPageBox__text
If you've forgotten your password, just enter your e-mail address below and we'll send you an email with a link which
will allow you to choose a new password.
= form_tag '', :class => 'loginForm' do
= error_messages_for @user
= hidden_field_tag 'return_to', params[:return_to]
%p.loginForm__input= password_field_tag 'password', params[:password], :class => 'input input--text input--onWhite', :placeholder => "Choose a new password", :autofocus => true, :tabindex => 1
%p.loginForm__input= password_field_tag 'password_confirmation', params[:password_confirmation], :class => 'input input--text input--onWhite', :placeholder => "and enter it again to confirm", :tabindex => 2
.loginForm__submit
%ul.loginForm__links
%li= link_to "Back to login", login_path(:return_to => params[:return_to])
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
================================================
FILE: app/views/sessions/new.html.haml
================================================
- page_title << "Login"
.subPageBox__title
Welcome to Postal
= display_flash
.subPageBox__content
= form_tag login_path, :class => 'loginForm' do
= hidden_field_tag 'return_to', params[:return_to]
- if Postal::Config.oidc.enabled?
.loginForm__oidcButton
= link_to "Login with #{Postal::Config.oidc.name}", "/auth/oidc", method: :post, class: 'button button--full'
- if Postal::Config.oidc.enabled? && Postal::Config.oidc.local_authentication_enabled?
.loginForm__divider
%p.loginForm__localTitle or login with a local user
- if Postal::Config.oidc.local_authentication_enabled?
%p.loginForm__input= text_field_tag 'email_address', '', :type => 'email', :spellcheck => 'false', :class => 'input input--text input--onWhite', :placeholder => "Your e-mail address", :autofocus => !Postal::Config.oidc.enabled?, :tabindex => 1
%p.loginForm__input= password_field_tag 'password', '', :class => 'input input--text input--onWhite', :placeholder => "Your password", :tabindex => 2
.loginForm__submit
%ul.loginForm__links
%li= link_to "Forgotten your password?", login_reset_path(:return_to => params[:return_to])
%p= submit_tag "Login", :class => 'button button--positive', :tabindex => 3
================================================
FILE: app/views/shared/_message_db_pagination.html.haml
================================================
.simplePagination
%p.simplePagination__previous
- if data[:page] > 1
= link_to "← Previous page".html_safe, request.params.merge(:page => data[:page] - 1), :class => 'simplePagination__link'
.simplePagination__current
%p.simplePagination__info Showing #{number_with_delimiter data[:records].size} of #{number_with_delimiter data[:total]} #{data[:total] == 1 ? name : name.pluralize}
%p Page #{data[:page]} of #{number_with_delimiter data[:total_pages]}
%p.simplePagination__next
- if data[:total_pages] > data[:page]
= link_to "Next page →".html_safe, request.params.merge(:page => data[:page] + 1), :class => 'simplePagination__link'
================================================
FILE: app/views/smtp_endpoints/_form.html.haml
================================================
= form_for [organization, @server, @smtp_endpoint], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :name, :autofocus => true, :class => 'input input--text'
.fieldSet__field
= f.label :hostname, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :hostname, :class => 'input input--text'
.fieldSet__field
= f.label :port, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :port, :class => 'input input--text', :placeholder => "25 (by default)"
.fieldSet__field
= f.label :ssl_mode, :class => 'fieldSet__label'
.fieldSet__input
= f.select :ssl_mode, SMTPEndpoint::SSL_MODES, {}, :class => 'input input--select'
%p.fieldSet__text
Choose what, if any, SSL mode you'd like to use when delivering mail to this mail server.
Be aware that any mail sent with no SSL is insecure and not protected in anyway.
.fieldSetSubmit.buttonSet
= f.submit @smtp_endpoint.new_record? ? "Create SMTP endpoint" : "Save SMTP endpoint", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete SMTP endpoint", [organization, @server, @smtp_endpoint], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this SMTP endpoint?\n\r#{pluralize @smtp_endpoint.routes.size, 'route'} that uses this endpoint will also be deleted."}
= hidden_field_tag 'return_to', params[:return_to]
= hidden_field_tag 'return_notice', params[:return_notice]
================================================
FILE: app/views/smtp_endpoints/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "SMTP Endpoints"
- page_title << "Edit"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :smtp_endpoints
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/smtp_endpoints/index.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "SMTP Endpoints"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :smtp_endpoints
.pageContent.pageContent--compact
- if @smtp_endpoints.empty?
.noData.noData--clean
%h2.noData__title There are no SMTP endpoints for this server.
%p.noData__text
SMTP endpoints are other mail servers that you'd like incoming e-mails
to be passed onto. Once you've added some endpoints, you can route messages
to them by creating #{link_to 'routes', [organization, @server, :routes], :class => 'u-link'}.
%p.noData__button
= link_to "Add your first SMTP endpoint", [:new, organization, @server, :smtp_endpoint], :class => 'button button--positive'
- else
%ul.endpointList.u-margin
- for endpoint in @smtp_endpoints
%li.endpointList__item
= link_to [:edit, organization, @server, endpoint], :class => 'endpointList__link' do
.endpointList__main
%p.endpointList__name= endpoint.name
%p.endpointList__url= endpoint.hostname
%ul.endpointList__details
%li.endpointList__detailItem
- if endpoint.last_used_at
Last used #{distance_of_time_in_words_to_now endpoint.last_used_at} ago
- else
Not used yet
%p.u-center= link_to "Add another SMTP endpoint", [:new, organization, @server, :smtp_endpoint], :class => 'button button--positive'
================================================
FILE: app/views/smtp_endpoints/new.html.haml
================================================
- page_title << @server.name
- page_title << "Routing"
- page_title << "SMTP Endpoints"
- page_title << "New"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :routing
= render 'routes/header', :active_nav => :smtp_endpoints
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/track_domains/_form.html.haml
================================================
= form_for [organization, @server, @track_domain], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, "Domain", :class => 'fieldSet__label'
.fieldSet__input
.routeNameInput
= f.text_field :name, :autofocus => true, :class => 'input input--text routeNameInput__name', :disabled => @track_domain.persisted?
%span.routeNameInput__at .
= f.select :domain_id, domain_options_for_select(@server, @track_domain.domain), {}, :class => 'input input--select routeNameInput__domain', :disabled => @track_domain.persisted?
%p.fieldSet__text
This is the domain that requests for tracked links will be directed through when you use click tracking. We recommend using something like
click.yourdomain.com . Once chosen, add a CNAME record which points to #{Postal::Config.dns.track_domain} .
.fieldSet__field
= f.label :ssl_enabled, :class => 'fieldSet__label'
.fieldSet__input
= f.select :ssl_enabled, [["Yes - use SSL for tracking whenever possible", true], ["No - never use SSL for tracking", false]], {}, :class => 'input input--select'
%p.fieldSet__text
If enabled, we'll use https for the tracking domain when replacing links and images. Please note that a SSL certificate
should be installed on the tracking domain if enabled.
.fieldSet__field
= f.label :track_loads, :class => 'fieldSet__label'
.fieldSet__input
= f.select :track_loads, [["Yes - track when HTML e-mails are opened", true], ["No - don't track when HTML e-mails are opened", false]], {}, :class => 'input input--select'
%p.fieldSet__text
If enabled, we'll insert a 1px image into the footer of any HTML e-mails. When this image is loaded, we'll log
this as a view and notify you with a webhook.
.fieldSet__field
= f.label :track_clicks, :class => 'fieldSet__label'
.fieldSet__input
= f.select :track_clicks, [["Yes - track when links are clicked", true], ["No - don't track when links are clicked", false]], {}, :class => 'input input--select'
%p.fieldSet__text
If enabled, we'll rewrite URLs in your outbound messages to go via this domain. You'll receive a webhook when
someone clicks one of your links and it will be displayed in the web interface.
.fieldSet__field
= f.label :excluded_click_domains, "Domains excluded from tracking", :class => 'fieldSet__label'
.fieldSet__input
~ f.text_area :excluded_click_domains, :class => 'input input--smallArea'
%p.fieldSet__text
This is a list of domains of links that you don't wish to be tracked. When click tracking is enabled,
you can provide a list (one domain per line) for links that you don't wish to be tracked.
.fieldSetSubmit.buttonSet
= f.submit @track_domain.new_record? ? "Create Track Domain" : "Save Track Domain", :class => 'button button--positive js-form-submit'
================================================
FILE: app/views/track_domains/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Tracking Domains"
- page_title << "Edit Tracking Domain Setting"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'domains/nav', :active_nav => :track_domains
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/track_domains/index.html.haml
================================================
- page_title << @server.name
- page_title << "Tracking Domains"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'domains/nav', :active_nav => :track_domains
.pageContent.pageContent--compact
- if @track_domains.empty?
.noData.noData--clean
%h2.noData__title There are no tracking domains for this server.
%p.noData__text
To use Postal's open & click tracking, you need to configure a domain that links will be re-written to use. Enable
message tracking by adding a suitable tracking domain for your outbound e-mails.
%p.noData__button= link_to "Add a custom tracking domain", [:new, organization, @server, :track_domain], :class => "button button--positive"
- else
%ul.domainList.u-margin
- for track_domain in @track_domains
%li.domainList__item
.domainList__details
%p.domainList__name
= link_to track_domain.full_name, [:edit, organization, @server, track_domain]
%ul.domainList__checks
- if track_domain.dns_status == 'OK'
%li.domainList__check.domainList__check--ok CNAME configured correctly
- elsif track_domain.dns_status.nil?
%li.domainList__check.domainList__check--neutral-cross CNAME/DNS not checked yet
- else
%li.domainList__check.domainList__check--warning{:title => track_domain.dns_error} CNAME not configured correctly
- if track_domain.ssl_enabled?
%li.domainList__check.domainList__check--neutral= link_to "SSL enabled", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post
- else
%li.domainList__check.domainList__check--neutral-cross= link_to "SSL disabled", [:toggle_ssl, organization, @server, track_domain], :remote => true, :method => :post
%ul.domainList__properties
%li.domainList__links
= link_to "Settings", [:edit, organization, @server, track_domain]
= link_to "Check DNS", [:check, organization, @server, track_domain], :remote => true, :method => :post, :data => {:disable_with => "Checking..."}
= link_to "Delete", [organization, @server, track_domain], :remote => true, :method => :delete, :data => {:confirm => "Are you sure you wish to remove this domain?", :disable_with => "Deleting..."}, :class => 'domainList__delete'
%p.u-center= link_to "Add new track domain", [:new, organization, @server, :track_domain], :class => "button button--positive"
================================================
FILE: app/views/track_domains/new.html.haml
================================================
- page_title << @server.name
- page_title << "Tracking Domains"
- page_title << "New Tracking Domain"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :domains
= render 'domains/nav', :active_nav => :track_domains
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/user/edit.html.haml
================================================
- page_title << "My Settings"
.pageHeader
%h1.pageHeader__title
My Settings
.pageContent.pageContent--compact
= form_for @user, :url => settings_path, :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
.fieldSet__field
= label_tag :password, 'Your Password', :class => 'fieldSet__label'
.fieldSet__input
= password_field_tag :password, params[:password], :autofocus => @password_correct.nil?, :disabled => @password_correct, :class => 'input input--text', :placeholder => "Enter your current password to change your details"
- if @password_correct
= hidden_field_tag :password, params[:password]
%p.fieldSet__text
In order to protect your account, you need to enter your current password in the field above
to authenticate the change of your details.
.fieldSet__title
Your details
.fieldSet__field
= f.label :first_name, "Name", :class => 'fieldSet__label'
.fieldSet__input
.inputPair
= f.text_field :first_name, :class => 'input input--text', :autofocus => @password_correct
= f.text_field :last_name, :class => 'input input--text'
.fieldSet__field
= f.label :email_address, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :email_address, :class => 'input input--text'
%p.fieldSet__text
If you change your e-mail address, you'll need to verify that you own the new one before
you can continue using your account.
.fieldSet__field
= f.label :time_zone, :class => 'fieldSet__label'
.fieldSet__input
= f.time_zone_select :time_zone, [], {}, :class => 'input input--select'
%p.fieldSet__text
Choose the time zone that you'd like times to be displayed to you when you use our
web interface. By default, times are displayed in UTC.
- if @user.password? && Postal::Config.oidc.local_authentication_enabled?
.fieldSet__title
Change your password?
.fieldSet__field
= f.label :password, "New Password", :class => 'fieldSet__label'
.fieldSet__input
.inputPair
= f.password_field :password, :class => 'input input--text', :placeholder => "•••••••••••", :value => @user.password
= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => "and confirm it", :value => @user.password_confirmation
%p.fieldSetSubmit.buttonSet
= f.submit "Save Settings", :class => 'button button--positive js-form-submit'
================================================
FILE: app/views/users/_form.html.haml
================================================
= form_for @user, :url => @user.new_record? ? users_path : user_path(@user), :remote => true do |f|
= f.error_messages
.fieldSet
.fieldSet__title
Enter user details
.fieldSet__field
= f.label :first_name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :first_name, :class => 'input input--text', :autofocus => true
.fieldSet__field
= f.label :last_name, :class => 'fieldSet__label'
.fieldSet__input= f.text_field :last_name, :class => 'input input--text'
.fieldSet__field
= f.label :email_address, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :email_address, :class => 'input input--text', autocomplete: 'one-time-code'
- if Postal::Config.oidc.enabled?
%p.fieldSet__text
This e-mail address should match the address provided by your OpenID Connect identity provider.
- if Postal::Config.oidc.local_authentication_enabled? && !@user.persisted?
.fieldSet__field
= f.label :password, :class => 'fieldSet__label'
.fieldSet__input
= f.password_field :password, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
- if Postal::Config.oidc.enabled?
%p.fieldSet__text
You have enabled OIDC which means a password is not required. If you do not provide
a password this user will be matched to an OIDC identity based on the e-mail address
provided above. You may, however, enter a password and this user will be permitted to
use that password until they have successfully logged in with OIDC.
.fieldSet__field
= f.label :password_confirmation, "Confirm".html_safe, :class => 'fieldSet__label'
.fieldSet__input= f.password_field :password_confirmation, :class => 'input input--text', :placeholder => '•••••••••••', autocomplete: 'one-time-code'
%fieldset.fieldSet
.fieldSet__title
What level of access do you wish to grant?
.fieldSet__titleSubText
Admin users have full access to all organizations and settings. Non-admin users will only
have access to the organizations that you select here.
.fieldSet__field
.fieldSet__label Admin?
.fieldSet__input
= hidden_field_tag 'user[organization_ids][]'
= f.select :admin, [["Yes - grant admin access", true], ["No - do not grant admin access", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle'
%ul.checkboxList{:class => [@user.admin? ? 'is-hidden' : '']}
- for org in Organization.order(:name).to_a
%li.checkboxList__item
.checkboxList__checkbox= check_box_tag "user[organization_ids][]", org.id, @user.organizations.include?(org), :id => "org_#{org.id}"
.checkboxList__label
= label_tag "org_#{org.id}", org.name, :class => 'checkboxList__actualLabel'
.fieldSetSubmit.buttonSet
= submit_tag @user.new_record? ? "Add User" : "Save User", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
= link_to "Back to user list", :users, :class => 'button button--neutral'
================================================
FILE: app/views/users/edit.html.haml
================================================
- page_title << "Users"
- page_title << "Permissions"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= link_to 'Users', users_path
→
Edit user permissions
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/users/index.html.haml
================================================
- page_title << "Users"
.pageHeader
%h1.pageHeader__title
Users
.pageContent.pageContent--compact
%ul.userList.u-margin
- for user in @users
%li.userList__item
.userList__details
%p.userList__name
= user.name
- if user.admin?
%span.userList__tag.label.label--blue Admin
- if Postal::Config.oidc.enabled?
- if user.oidc?
%span.userList__tag.label.label--green OIDC
- elsif !Postal::Config.oidc.local_authentication_enabled?
%span.userList__tag.label.label--orange Pending
%p.userList__email= user.email_address
%ul.userList__actions
%li= link_to "Edit user", [:edit, user]
%li= link_to "Delete user", user, :method => :delete, :data => {:confirm => "Are you sure you wish to revoke #{user.name}'s access?", :disable_with => "Deleting..."}, :remote => true, :class => 'userList__revoke'
%p.u-center= link_to "Add a new user", :new_user, :class => 'button button--positive'
================================================
FILE: app/views/users/new.html.haml
================================================
- page_title << "Users"
- page_title << "Add"
.pageHeader
%h1.pageHeader__title
%span.pageHeader__titlePrevious
= link_to 'Users', :users
→
Add user
.pageContent.pageContent--compact
%p.pageContent__intro.u-margin
To add someone to this Postal installation, you can add them below. You'll need to
choose whether to make them an admin (full access to all organizations and settings)
or whether you wish to limit them to specific organizations.
= render 'form'
================================================
FILE: app/views/webhooks/_form.html.haml
================================================
= form_for [organization, @server, @webhook], :remote => true do |f|
= f.error_messages
%fieldset.fieldSet
.fieldSet__field
= f.label :name, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :name, :autofocus => true, :class => 'input input--text'
%p.fieldSet__text
Enter a name to describe this webhook. This is used so you can identify this webhook later in the web interface.
.fieldSet__field
= f.label :url, :class => 'fieldSet__label'
.fieldSet__input
= f.text_field :url, :class => 'input input--text'
%p.fieldSet__text
Enter the URL that you'd like us to send requests to. All requests will be POST requests with
a JSON-encoded payload in the body of the request.
.fieldSet__field
= f.label :enabled, :class => 'fieldSet__label'
.fieldSet__input
= f.select :enabled, [["Yes - send requests to this webhook", true], ["No - do not send requests at the moment", false]], {},:class => 'input input--select'
%p.fieldSet__text
You can enable or disable this webhook without fully removing it from the system. If there are any outstanding
webhook deliveries, they will still be completed even if disabled.
.fieldSet__field
= f.label :all_events, 'Events', :class => 'fieldSet__label'
.fieldSet__input
= hidden_field_tag 'webhook[events][]'
= f.select :all_events, [["Yes - send all events to this URL", true], ["No - I'll choose which requests to send", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle'
%ul.checkboxList{:class => [@webhook.all_events? ? 'is-hidden' : '']}
- for event in WebhookEvent::EVENTS
%li.checkboxList__item
.checkboxList__checkbox= check_box_tag "webhook[events][]", event, @webhook.events.include?(event), :id => "event_#{event}"
.checkboxList__label
= label_tag "event_#{event}", event, :class => 'checkboxList__actualLabel checkboxList__devEvent'
%p.checkBoxList__text= t("webhook_events.#{event.underscore}")
.fieldSetSubmit.buttonSet
= f.submit @webhook.new_record? ? "Create Webhook" : "Save Webhook", :class => 'button button--positive js-form-submit'
.fieldSetSubmit__delete
- if f.object.persisted?
= link_to "Delete Webhook", [organization, @server, @webhook], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this webhook?"}
================================================
FILE: app/views/webhooks/_header.html.haml
================================================
.navBar.navBar--secondary
%ul
%li.navBar__item= link_to "Manage Webhooks", [organization, @server, :webhooks], :class => ['navBar__link', active_nav == :webhooks ? 'is-active' : '']
%li.navBar__item= link_to "View History", [:history, organization, @server, :webhooks], :class => ['navBar__link', active_nav == :history ? 'is-active' : '']
================================================
FILE: app/views/webhooks/edit.html.haml
================================================
- page_title << @server.name
- page_title << "Webhooks"
- page_title << "Edit"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :webhooks
= render 'header', :active_nav => :webhooks
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: app/views/webhooks/history.html.haml
================================================
- page_title << @server.name
- page_title << "Webhooks"
- page_title << "History"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :webhooks
= render 'header', :active_nav => :history
.pageContent.pageContent--compact
- if @requests[:records].empty?
.noData.noData--clean
%h2.noData__title No webhook requests recorded.
%p.noData__text
This page shows the last 10 days worth of webhook requests that have been sent by Postal. This page will
populate automatically as webhooks are dispatched.
- else
%p.pageContent__intro.u-margin
This page shows a list of all webhook requests which have been sent for this server. These are kept for 10 days before being
removed. Click on a request for additional information.
%ul.webhookRequestList
- for req in @requests[:records]
%li.webhookRequestList__item
= link_to history_request_organization_server_webhooks_path(organization, @server, req.uuid), :class => 'webhookRequestList__link' do
.webhookRequestList__top
%p.webhookRequestList__status
%span.label{:class => "label--http-status-#{req.status_code.to_s[0,1]}"}= req.status_code
%p.webhookRequestList__time= req.timestamp.strftime("%d/%m/%Y at %H:%M:%S")
%p.webhookRequestList__event= req.event
%p.webhookRequestList__url= req.url || "Unknown"
= render 'shared/message_db_pagination', :data => @requests, :name => 'request'
================================================
FILE: app/views/webhooks/history_request.html.haml
================================================
- page_title << @server.name
- page_title << "Webhooks"
- page_title << "History"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :webhooks
= render 'header', :active_nav => :history
.pageContent.pageContent--compact
%dl.pageContent__definitions.u-margin
%dt URL
%dd= @req.url
%dt Event
%dd= @req.event
%dt UUID
%dd= @req.uuid
%dt Timestamp
%dd= @req.timestamp.strftime("%d/%m/%Y at %H:%M:%S")
%dt HTTP Status Code
%dd
%span.label.label--large{:class => "label--http-status-#{@req.status_code.to_s[0,1]}"}= @req.status_code
%dt Attempt
%dd
= @req.attempt
- if @req.will_retry?
(will be retried)
%p.pageContent__title Payload
%pre.codeBlock.u-margin.codeBlock--whitespace~ preserve @req.pretty_payload
%p.pageContent__title Response Body
%pre.codeBlock.u-margin= @req.body
%p.u-margin= link_to "Back to history", [:history, organization, @server, :webhooks], :class => "button button--neutral"
================================================
FILE: app/views/webhooks/index.html.haml
================================================
- page_title << @server.name
- page_title << "Webhooks"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :webhooks
= render 'header', :active_nav => :webhooks
.pageContent.pageContent--compact
- if @webhooks.empty?
.noData.noData--clean
%h2.noData__title No webhooks have been configured for this server.
%p.noData__text
You haven't added any webhooks for this server yet. A webhook enables your web
application to be notified when certain events occur in the lifecycle of the mail server.
%p.noData__button= link_to "Add your first webhook", [:new, organization, @server, :webhook], :class => "button button--positive"
- else
%ul.webhookList.u-margin
- for webhook in @webhooks
%li.webhookList__item
.webhookList__top
%p.webhookList__name= link_to webhook.name, [:edit, organization, @server, webhook]
%p.webhookList__labels
- if webhook.enabled?
%span.label.label--green Enabled
- else
%span.label.label--red Disabled
.webhookList__bottom
%p.webhookList__usageTime
- if webhook.last_used_at
Last sent request #{distance_of_time_in_words_to_now webhook.last_used_at}.
- else
Not used yet.
%ul.webhookList__links
%li.webhookList__link= link_to "Edit Webhook", [:edit, organization, @server, webhook]
%p.u-center= link_to "Add another webhook", [:new, organization, @server, :webhook], :class => 'button button--positive'
================================================
FILE: app/views/webhooks/new.html.haml
================================================
- page_title << @server.name
- page_title << "Webhooks"
- page_title << "New"
= render 'servers/sidebar', :active_server => @server
= render 'servers/header', :active_nav => :webhooks
= render 'header', :active_nav => :webhooks
.pageContent.pageContent--compact
= render 'form'
================================================
FILE: bin/bundle
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'bundle' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require "rubygems"
m = Module.new do
module_function
def invoked_as_script?
File.expand_path($0) == File.expand_path(__FILE__)
end
def env_var_version
ENV["BUNDLER_VERSION"]
end
def cli_arg_version
return unless invoked_as_script? # don't want to hijack other binstubs
return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
bundler_version = nil
update_index = nil
ARGV.each_with_index do |a, i|
if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
bundler_version = a
end
next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
bundler_version = $1
update_index = i
end
bundler_version
end
def gemfile
gemfile = ENV["BUNDLE_GEMFILE"]
return gemfile if gemfile && !gemfile.empty?
File.expand_path("../Gemfile", __dir__)
end
def lockfile
lockfile =
case File.basename(gemfile)
when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
else "#{gemfile}.lock"
end
File.expand_path(lockfile)
end
def lockfile_version
return unless File.file?(lockfile)
lockfile_contents = File.read(lockfile)
return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
Regexp.last_match(1)
end
def bundler_requirement
@bundler_requirement ||=
env_var_version ||
cli_arg_version ||
bundler_requirement_for(lockfile_version)
end
def bundler_requirement_for(version)
return "#{Gem::Requirement.default}.a" unless version
bundler_gem_version = Gem::Version.new(version)
bundler_gem_version.approximate_recommendation
end
def load_bundler!
ENV["BUNDLE_GEMFILE"] ||= gemfile
activate_bundler
end
def activate_bundler
gem_error = activation_error_handling do
gem "bundler", bundler_requirement
end
return if gem_error.nil?
require_error = activation_error_handling do
require "bundler/version"
end
return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
exit 42
end
def activation_error_handling
yield
nil
rescue StandardError, LoadError => e
e
end
end
m.load_bundler!
if m.invoked_as_script?
load Gem.bin_path("bundler", "bundle")
end
================================================
FILE: bin/dev
================================================
#!/usr/bin/env bash
if ! command -v foreman &> /dev/null
then
echo "Installing foreman..."
gem install foreman
fi
foreman start -f Procfile.dev
================================================
FILE: bin/postal
================================================
#!/bin/bash
ROOT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )
set -e
run() {
eval $@
}
# Enter the root directory
cd $ROOT_DIR
# Run the right command
case "$1" in
web-server)
run "bundle exec puma -C config/puma.rb"
;;
smtp-server)
run "bundle exec ruby script/smtp_server.rb"
;;
worker)
run "bundle exec ruby script/worker.rb"
;;
initialize)
echo 'Initializing database'
run "bundle exec rake db:create postal:update"
;;
upgrade)
run "bundle exec rake postal:update"
;;
update)
run "bundle exec rake postal:update"
;;
console)
run "bundle exec rails console"
;;
default-dkim-record)
run "bundle exec ruby script/default_dkim_record.rb"
;;
make-user)
run "bundle exec ruby script/make_user.rb"
;;
test-app-smtp)
run "bundle exec ruby script/test_app_smtp.rb $2"
;;
version)
run "bundle exec ruby script/version.rb"
;;
*)
echo "Usage: postal [command]"
echo
echo "Server components:"
echo
echo -e " * \033[35mweb-server\033[0m - run the web server"
echo -e " * \033[35msmtp-server\033[0m - run the SMTP server"
echo -e " * \033[35mworker\033[0m - run a worker"
echo
echo "Setup/upgrade tools:"
echo
echo -e " * \033[32minitialize\033[0m - create and load the DB schema"
echo -e " * \033[32mupdate\033[0m - upgrade the DB schema"
echo
echo "Other tools:"
echo
echo -e " * \033[34mversion\033[0m - show the current Postal version"
echo -e " * \033[34mmake-user\033[0m - create a new global admin user"
echo -e " * \033[34mdefault-dkim-record\033[0m - display the default DKIM record"
echo -e " * \033[34mconsole\033[0m - open an interactive console"
echo -e " * \033[34mtest-app-smtp\033[0m - send a test message through Postal"
echo
esac
================================================
FILE: bin/rails
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"
================================================
FILE: bin/rake
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative "../config/boot"
require "rake"
Rake.application.run
================================================
FILE: bin/rspec
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'rspec' is installed as part of a gem, and
# this file is here to facilitate running it.
#
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
bundle_binstub = File.expand_path("bundle", __dir__)
if File.file?(bundle_binstub)
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
load(bundle_binstub)
else
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
end
end
require "rubygems"
require "bundler/setup"
load Gem.bin_path("rspec-core", "rspec")
================================================
FILE: bin/setup
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
require "pathname"
require "fileutils"
include FileUtils
# path to your application root.
APP_ROOT = Pathname.new File.expand_path("..", __dir__)
def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==")
end
chdir APP_ROOT do
# This script is a starting point to setup your application.
# Add necessary setup steps to this file.
puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files =="
# unless File.exist?('config/database.yml')
# cp 'config/database.yml.sample', 'config/database.yml'
# end
puts "\n== Preparing database =="
system! "bin/rails db:setup"
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
puts "\n== Restarting application server =="
system! "bin/rails restart"
end
================================================
FILE: bin/update
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
require "pathname"
require "fileutils"
include FileUtils
# path to your application root.
APP_ROOT = Pathname.new File.expand_path("..", __dir__)
def system!(*args)
system(*args) || abort("\n== Command #{args} failed ==")
end
chdir APP_ROOT do
# This script is a way to update your development environment automatically.
# Add necessary update steps to this file.
puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install")
puts "\n== Updating database =="
system! "bin/rails db:migrate"
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
puts "\n== Restarting application server =="
system! "bin/rails restart"
end
================================================
FILE: config/application.rb
================================================
# frozen_string_literal: true
require_relative "boot"
require "rails"
require "active_model/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "sprockets/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
gem_groups = Rails.groups
gem_groups << :oidc if Postal::Config.oidc.enabled?
Bundler.require(*gem_groups)
module Postal
class Application < Rails::Application
config.load_defaults 7.0
# Disable most generators
config.generators do |g|
g.orm :active_record
g.test_framework false
g.stylesheets false
g.javascripts false
g.helper false
end
# Include from lib
config.eager_load_paths << Rails.root.join("lib")
# Disable field_with_errors
config.action_view.field_error_proc = proc { |t, _| t }
# Load the tracking server middleware
require "tracking_middleware"
config.middleware.insert_before ActionDispatch::HostAuthorization, TrackingMiddleware
config.hosts << Postal::Config.postal.web_hostname
unless Postal::Config.logging.rails_log_enabled?
config.logger = Logger.new("/dev/null")
end
end
end
================================================
FILE: config/boot.rb
================================================
# frozen_string_literal: true
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require_relative "../lib/postal/config"
ENV["RAILS_ENV"] = Postal::Config.rails.environment || "development"
================================================
FILE: config/database.yml
================================================
default: &default
adapter: mysql2
reconnect: true
encoding: "<%= Postal::Config.main_db.encoding %>"
pool: <%= Postal::Config.main_db.pool_size %>
username: "<%= Postal::Config.main_db.username %>"
password: "<%= Postal::Config.main_db.password %>"
host: "<%= Postal::Config.main_db.host %>"
port: <%= Postal::Config.main_db.port %>
database: "<%= Postal::Config.main_db.database %>"
development:
<<: *default
production:
<<: *default
test:
<<: *default
================================================
FILE: config/environment.rb
================================================
# frozen_string_literal: true
# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!
================================================
FILE: config/environments/development.rb
================================================
# frozen_string_literal: true
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# In the development environment your application's code is reloaded on
# every request. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
config.enable_reloading = true
# Do not eager load code on boot.
config.eager_load = false
# Show full error reports.
config.consider_all_requests_local = true
# Enable/disable caching. By default caching is disabled.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=172800"
}
else
config.action_controller.perform_caching = false
config.cache_store = :null_store
end
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = true
config.action_mailer.perform_caching = false
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load
# Debug mode disables concatenation and preprocessing of assets.
# This option may cause significant delays in view rendering with a large
# number of complex assets.
config.assets.debug = true
# Suppress logger output for asset requests.
config.assets.quiet = false
# Raises error for missing translations
# config.action_view.raise_on_missing_translations = true
# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
# config.file_watcher = ActiveSupport::EventedFileUpdateChecker
end
================================================
FILE: config/environments/production.rb
================================================
# frozen_string_literal: true
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
# Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers
# and those relying on copy on write to perform better.
# Rake tasks automatically ignore this option for performance.
config.eager_load = true
# Full error reports are disabled and caching is turned on.
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = true
# Compress JavaScripts and CSS.
config.assets.js_compressor = :uglifier
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = false
# `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.action_controller.asset_host = 'http://assets.example.com'
# Specifies the header that your server uses for sending files.
# config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
# config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
# config.force_ssl = true
# Use the lowest log level to ensure availability of diagnostic information
# when problems arise.
config.log_level = :info
# Prepend all log lines with the following tags.
config.log_tags = [:request_id]
# Use a different cache store in production.
# config.cache_store = :mem_cache_store
# Use a real queuing backend for Active Job (and separate queues per environment)
# config.active_job.queue_adapter = :resque
# config.active_job.queue_name_prefix = "deliver_#{Rails.env}"
config.action_mailer.perform_caching = false
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
config.action_mailer.raise_delivery_errors = true
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Send deprecation notices to registered listeners.
config.active_support.deprecation = :notify
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = Logger::Formatter.new
# Use a different logger for distributed setups.
# require 'syslog/logger'
# config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
end
================================================
FILE: config/environments/test.rb
================================================
# frozen_string_literal: true
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!
config.enable_reloading = false
# Do not eager load code on boot. This avoids loading your whole application
# just for the purpose of running a single test. If you are using a tool that
# preloads Rails for running tests, you may have to set it to true.
config.eager_load = false
# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=3600"
}
# Show full error reports and disable caching.
config.consider_all_requests_local = true
config.action_controller.perform_caching = false
# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = false
# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
config.action_mailer.perform_caching = false
# Tell Action Mailer not to deliver emails to the real world.
# The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
# Raises error for missing translations
# config.action_view.raise_on_missing_translations = true
end
================================================
FILE: config/examples/development.yml
================================================
# This is an example Postal configuration file for use in
# development environments. For a production example, see
# the https://github.com/postalserver/install repository.
version: 2
postal:
web_hostname: postal.example.com
web_protocol: https
smtp_hostname: postal.example.com
main_db:
host: 127.0.0.1
username: root
password:
database: postal
message_db:
host: 127.0.0.1
username: root
password:
prefix: postal
logging:
rails_log_enabled: true
highlighting_enabled: true
rails:
environment: development
secret_key: 7f27856d26e864bafd49d0df37ad3d1339086e86ef0447e0f1814dde5277452fea97dab9e3aad6dfa11bfe359c82ce302d97bf1e58f6103c4408e4fbad4eeccf
================================================
FILE: config/examples/test.yml
================================================
# This is an example Postal configuration file for use in
# test environments. For a production example, see
# the https://github.com/postalserver/install repository.
version: 2
main_db:
host: 127.0.0.1
username: root
password:
database: postal-test
message_db:
host: 127.0.0.1
username: root
password:
prefix: postal-test
logging:
enabled: false
rails_log_enabled: false
rails:
environment: test
secret_key: 7f27856d26e864bafd49d0df37ad3d1339086e86ef0447e0f1814dde5277452fea97dab9e3aad6dfa11bfe359c82ce302d97bf1e58f6103c4408e4fbad4eeccf
================================================
FILE: config/initializers/_wait_for_migrations.rb
================================================
# frozen_string_literal: true
require "migration_waiter"
MigrationWaiter.wait_if_appropriate
================================================
FILE: config/initializers/application_controller_renderer.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# ApplicationController.renderer.defaults.merge!(
# http_host: 'example.org',
# https: false
# )
================================================
FILE: config/initializers/assets.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Version of your assets, change this if you want to expire all your assets.
Rails.application.config.assets.version = "1.0"
# Add additional assets to the asset load path
# Rails.application.config.assets.paths << Emoji.images_path
# Precompile additional assets.
# application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
# Rails.application.config.assets.precompile += %w( search.js )
================================================
FILE: config/initializers/backtrace_silencers.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
# Rails.backtrace_cleaner.remove_silencers!
================================================
FILE: config/initializers/content_security_policy.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
================================================
FILE: config/initializers/cookies_serializer.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Specify a serializer for the signed and encrypted cookie jars.
# Valid options are :json, :marshal, and :hybrid.
Rails.application.config.action_dispatch.cookies_serializer = :json
================================================
FILE: config/initializers/filter_parameter_logging.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn,
]
================================================
FILE: config/initializers/inflections.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Add new inflection rules using the following format. Inflections
# are locale specific, and you may define rules for as many different
# locales as you wish. All of these examples are active by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.plural /^(ox)$/i, '\1en'
# inflect.singular /^(ox)en/i, '\1'
# inflect.irregular 'person', 'people'
# inflect.uncountable %w( fish sheep )
# end
# These inflection rules are supported but not enabled by default:
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "DKIM"
inflect.acronym "HTTP"
inflect.acronym "OIDC"
inflect.acronym "SMTP"
inflect.acronym "UUID"
inflect.acronym "API"
inflect.acronym "DNS"
inflect.acronym "SSL"
inflect.acronym "MySQL"
inflect.acronym "DB"
inflect.acronym "IP"
inflect.acronym "MQ"
inflect.acronym "MX"
end
================================================
FILE: config/initializers/logging.rb
================================================
# frozen_string_literal: true
begin
def add_exception_to_payload(payload, event)
return unless exception = event.payload[:exception_object]
payload[:exception_class] = exception.class.name
payload[:exception_message] = exception.message
payload[:exception_backtrace] = exception.backtrace[0, 4].join("\n")
end
ActiveSupport::Notifications.subscribe "process_action.action_controller" do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
payload = {
event: "request",
transaction: event.transaction_id,
controller: event.payload[:controller],
action: event.payload[:action],
format: event.payload[:format],
method: event.payload[:method],
path: event.payload[:path],
request_id: event.payload[:request].request_id,
ip_address: event.payload[:request].ip,
status: event.payload[:status],
view_runtime: event.payload[:view_runtime],
db_runtime: event.payload[:db_runtime]
}
add_exception_to_payload(payload, event)
string = "#{payload[:method]} #{payload[:path]} (#{payload[:status]})"
if payload[:exception_class]
Postal.logger.error(string, **payload)
else
Postal.logger.info(string, **payload)
end
end
ActiveSupport::Notifications.subscribe "deliver.action_mailer" do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Postal.logger.info({
event: "send_email",
transaction: event.transaction_id,
message_id: event.payload[:message_id],
subject: event.payload[:subject],
from: event.payload[:from],
to: event.payload[:to].is_a?(Array) ? event.payload[:to].join(", ") : event.payload[:to].to_s
})
end
end
================================================
FILE: config/initializers/mail_extensions.rb
================================================
# frozen_string_literal: true
require "mail"
module Mail
module Encodings
# Handle windows-1258 as windows-1252 when decoding
def self.q_value_decode(str)
str = str.sub(/=\?windows-?1258\?/i, '\=?windows-1252?')
Utilities.q_value_decode(str)
end
def self.b_value_decode(str)
str = str.sub(/=\?windows-?1258\?/i, '\=?windows-1252?')
Utilities.b_value_decode(str)
end
end
class Message
## Extract plain text body of message
def plain_body
if multipart? && text_part
text_part.decoded
elsif mime_type == "text/plain" || mime_type.nil?
decoded
end
end
## Extract HTML text body of message
def html_body
if multipart? && html_part
html_part.decoded
elsif mime_type == "text/html"
decoded
end
end
private
## Fix bug in basic parsing
def parse_message
self.header, self.body = raw_source.split(/\r?\n\r?\n/m, 2)
end
# Handle attached emails as attachments
# Returns the filename of the attachment (if it exists) or returns nil
# Make up a filename for rfc822 attachments if it isn't specified
def find_attachment
content_type_name = begin
header[:content_type].filename
rescue StandardError
nil
end
content_disp_name = begin
header[:content_disposition].filename
rescue StandardError
nil
end
content_loc_name = begin
header[:content_location].location
rescue StandardError
nil
end
if content_type && content_type_name
filename = content_type_name
elsif content_disposition && content_disp_name
filename = content_disp_name
elsif content_location && content_loc_name
filename = content_loc_name
elsif mime_type == "message/rfc822"
filename = "#{rand(100_000_000)}.eml"
else
filename = nil
end
if filename
# Normal decode
filename = begin
Mail::Encodings.decode_encode(filename, :decode)
rescue StandardError
filename
end
end
filename
end
def decode_body_as_text
body_text = decode_body
charset_tmp = begin
Encoding.find(Utilities.pick_encoding(charset))
rescue StandardError
"ASCII"
end
charset_tmp = "Windows-1252" if charset_tmp.to_s =~ /windows-?1258/i
if charset_tmp == Encoding.find("UTF-7")
body_text.force_encoding("UTF-8")
decoded = body_text.gsub(/\+.*?-/m) { |n| Base64.decode64(n[1..-2] + "===").force_encoding("UTF-16BE").encode("UTF-8") }
else
body_text.force_encoding(charset_tmp)
decoded = body_text.encode("utf-8", invalid: :replace, undef: :replace)
end
decoded.valid_encoding? ? decoded : decoded.encode("utf-16le", invalid: :replace, undef: :replace).encode("utf-8")
end
end
# Handle attached emails as attachments
class AttachmentsList < Array
# rubocop:disable Lint/MissingSuper
def initialize(parts_list)
@parts_list = parts_list
@content_disposition_type = "attachment"
parts = parts_list.map do |p|
p.parts.empty? && p.attachment? ? p : p.attachments
end.flatten.compact
parts.each { |a| self << a }
end
# rubocop:enable Lint/MissingSuper
end
end
class Array
def decoded
return nil if empty?
first.decoded
end
end
class NilClass
def decoded
nil
end
end
================================================
FILE: config/initializers/mime_types.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Add new mime types for use in respond_to blocks:
# Mime::Type.register "text/richtext", :rtf
================================================
FILE: config/initializers/new_framework_defaults_7_0.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
#
# This file eases your Rails 7.0 framework defaults upgrade.
#
# Uncomment each configuration one by one to switch to the new default.
# Once your application is ready to run with all new defaults, you can remove
# this file and set the `config.load_defaults` to `7.0`.
#
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
# `button_to` view helper will render `` element, regardless of whether
# or not the content is passed as the first argument or as a block.
# Rails.application.config.action_view.button_to_generates_button_tag = true
# `stylesheet_link_tag` view helper will not render the media attribute by default.
# Rails.application.config.action_view.apply_stylesheet_media_default = false
# Change the digest class for the key generators to `OpenSSL::Digest::SHA256`.
# Changing this default means invalidate all encrypted messages generated by
# your application and, all the encrypted cookies. Only change this after you
# rotated all the messages using the key rotator.
#
# See upgrading guide for more information on how to build a rotator.
# https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html
# Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256
# Change the digest class for ActiveSupport::Digest.
# Changing this default means that for example Etags change and
# various cache keys leading to cache invalidation.
# Rails.application.config.active_support.hash_digest_class = OpenSSL::Digest::SHA256
# Don't override ActiveSupport::TimeWithZone.name and use the default Ruby
# implementation.
# Rails.application.config.active_support.remove_deprecated_time_with_zone_name = true
# Calls `Rails.application.executor.wrap` around test cases.
# This makes test cases behave closer to an actual request or job.
# Several features that are normally disabled in test, such as Active Record query cache
# and asynchronous queries will then be enabled.
# Rails.application.config.active_support.executor_around_test_case = true
# Set both the `:open_timeout` and `:read_timeout` values for `:smtp` delivery method.
# Rails.application.config.action_mailer.smtp_timeout = 5
# The ActiveStorage video previewer will now use scene change detection to generate
# better preview images (rather than the previous default of using the first frame
# of the video).
# Rails.application.config.active_storage.video_preview_arguments =
# "-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015),loop=loop=-1:size=2,trim=start_frame=1' -frames:v 1 -f image2"
# Automatically infer `inverse_of` for associations with a scope.
# Rails.application.config.active_record.automatic_scope_inversing = true
# Raise when running tests if fixtures contained foreign key violations
# Rails.application.config.active_record.verify_foreign_keys_for_fixtures = true
# Disable partial inserts.
# This default means that all columns will be referenced in INSERT queries
# regardless of whether they have a default or not.
# Rails.application.config.active_record.partial_inserts = false
# Protect from open redirect attacks in `redirect_back_or_to` and `redirect_to`.
# Rails.application.config.action_controller.raise_on_open_redirects = true
# Change the variant processor for Active Storage.
# Changing this default means updating all places in your code that
# generate variants to use image processing macros and ruby-vips
# operations. See the upgrading guide for detail on the changes required.
# The `:mini_magick` option is not deprecated; it's fine to keep using it.
# Rails.application.config.active_storage.variant_processor = :vips
# Enable parameter wrapping for JSON.
# Previously this was set in an initializer. It's fine to keep using that initializer if you've customized it.
# To disable parameter wrapping entirely, set this config to `false`.
# Rails.application.config.action_controller.wrap_parameters_by_default = true
# Specifies whether generated namespaced UUIDs follow the RFC 4122 standard for namespace IDs provided as a
# `String` to `Digest::UUID.uuid_v3` or `Digest::UUID.uuid_v5` method calls.
#
# See https://guides.rubyonrails.org/configuring.html#config-active-support-use-rfc4122-namespaced-uuids for
# more information.
# Rails.application.config.active_support.use_rfc4122_namespaced_uuids = true
# Change the default headers to disable browsers' flawed legacy XSS protection.
# Rails.application.config.action_dispatch.default_headers = {
# "X-Frame-Options" => "SAMEORIGIN",
# "X-XSS-Protection" => "0",
# "X-Content-Type-Options" => "nosniff",
# "X-Download-Options" => "noopen",
# "X-Permitted-Cross-Domain-Policies" => "none",
# "Referrer-Policy" => "strict-origin-when-cross-origin"
# }
# ** Please read carefully, this must be configured in config/application.rb **
# Change the format of the cache entry.
# Changing this default means that all new cache entries added to the cache
# will have a different format that is not supported by Rails 6.1 applications.
# Only change this value after your application is fully deployed to Rails 7.0
# and you have no plans to rollback.
# When you're ready to change format, add this to `config/application.rb` (NOT this file):
# config.active_support.cache_format_version = 7.0
# Cookie serializer: 2 options
#
# If you're upgrading and haven't set `cookies_serializer` previously, your cookie serializer
# is `:marshal`. The default for new apps is `:json`.
#
# Rails.application.config.action_dispatch.cookies_serializer = :json
#
#
# To migrate an existing application to the `:json` serializer, use the `:hybrid` option.
#
# Rails transparently deserializes existing (Marshal-serialized) cookies on read and
# re-writes them in the JSON format.
#
# It is fine to use `:hybrid` long term; you should do that until you're confident *all* your cookies
# have been converted to JSON. To keep using `:hybrid` long term, move this config to its own
# initializer or to `config/application.rb`.
#
# Rails.application.config.action_dispatch.cookies_serializer = :hybrid
#
#
# If your cookies can't yet be serialized to JSON, keep using `:marshal` for backward-compatibility.
#
# If you have configured the serializer elsewhere, you can remove this section of the file.
#
# See https://guides.rubyonrails.org/action_controller_overview.html#cookies for more information.
# Change the return value of `ActionDispatch::Request#content_type` to the Content-Type header without modification.
# Rails.application.config.action_dispatch.return_only_request_media_type_on_content_type = false
# Active Storage `has_many_attached` relationships will default to replacing the current collection instead of appending to it.
# Thus, to support submitting an empty collection, the `file_field` helper will render an hidden field `include_hidden` by default when `multiple_file_field_include_hidden` is set to `true`.
# See https://guides.rubyonrails.org/configuring.html#config-active-storage-multiple-file-field-include-hidden for more information.
# Rails.application.config.active_storage.multiple_file_field_include_hidden = true
# ** Please read carefully, this must be configured in config/application.rb (NOT this file) **
# Disables the deprecated #to_s override in some Ruby core classes
# See https://guides.rubyonrails.org/configuring.html#config-active-support-disable-to-s-conversion for more information.
# config.active_support.disable_to_s_conversion = true
================================================
FILE: config/initializers/omniauth.rb
================================================
# frozen_string_literal: true
config = Postal::Config.oidc
if config.enabled?
client_options = { identifier: config.identifier, secret: config.secret }
client_options[:redirect_uri] = "#{Postal::Config.postal.web_protocol}://#{Postal::Config.postal.web_hostname}/auth/oidc/callback"
unless config.discovery?
client_options[:authorization_endpoint] = config.authorization_endpoint
client_options[:token_endpoint] = config.token_endpoint
client_options[:userinfo_endpoint] = config.userinfo_endpoint
client_options[:jwks_uri] = config.jwks_uri
end
Rails.application.config.middleware.use OmniAuth::Builder do
provider :openid_connect, name: :oidc,
scope: config.scopes.map(&:to_sym),
uid_field: config.uid_field,
issuer: config.issuer,
discovery: config.discovery?,
client_options: client_options
end
OmniAuth.config.on_failure = proc do |env|
SessionsController.action(:oauth_failure).call(env)
end
end
================================================
FILE: config/initializers/permissions_policy.rb
================================================
# frozen_string_literal: true
# Define an application-wide HTTP permissions policy. For further
# information see https://developers.google.com/web/updates/2018/06/feature-policy
#
# Rails.application.config.permissions_policy do |f|
# f.camera :none
# f.gyroscope :none
# f.microphone :none
# f.usb :none
# f.fullscreen :self
# f.payment :self, "https://secure.example.com"
# end
================================================
FILE: config/initializers/postal.rb
================================================
# frozen_string_literal: true
require "postal"
================================================
FILE: config/initializers/record_key_for_dom.rb
================================================
# frozen_string_literal: true
module ActionView
module RecordIdentifier
def dom_id(record, prefix = nil)
if record.new_record?
dom_class(record, prefix || NEW)
else
id = record.respond_to?(:uuid) ? record.uuid : record.id
"#{dom_class(record, prefix)}#{JOIN}#{id}"
end
end
end
end
================================================
FILE: config/initializers/secret_key.rb
================================================
# frozen_string_literal: true
if Postal::Config.rails.secret_key
Rails.application.credentials.secret_key_base = Postal::Config.rails.secret_key
else
warn "No secret key was specified in the Postal config file. Using one for just this session"
Rails.application.credentials.secret_key_base = SecureRandom.hex(128)
end
================================================
FILE: config/initializers/secure_headers.rb
================================================
# frozen_string_literal: true
SecureHeaders::Configuration.default do |config|
config.hsts = SecureHeaders::OPT_OUT
config.csp[:default_src] = []
config.csp[:script_src] = ["'self'"]
config.csp[:child_src] = ["'self'"]
config.csp[:connect_src] = ["'self'"]
end
================================================
FILE: config/initializers/sentry.rb
================================================
# frozen_string_literal: true
require "postal/config"
if Postal::Config.logging.sentry_dsn
Sentry.init do |config|
config.dsn = Postal::Config.logging.sentry_dsn
end
end
================================================
FILE: config/initializers/session_store.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
Rails.application.config.session_store :cookie_store, key: "_postal_session"
================================================
FILE: config/initializers/smtp.rb
================================================
# frozen_string_literal: true
require "postal/config"
config = Postal::Config.smtp
ActionMailer::Base.delivery_method = :smtp
ActionMailer::Base.smtp_settings = {
address: config.host,
user_name: config.username,
password: config.password,
port: config.port,
authentication: config.authentication_type&.to_sym,
enable_starttls: config.enable_starttls?,
enable_starttls_auto: config.enable_starttls_auto?,
openssl_verify_mode: config.openssl_verify_mode
}
================================================
FILE: config/initializers/smtp_extensions.rb
================================================
# frozen_string_literal: true
module Net
class SMTP
attr_accessor :source_address
def secure_socket?
return false unless @socket
@socket.io.is_a?(OpenSSL::SSL::SSLSocket)
end
#
# We had an issue where a message was sent to a server and was greylisted. It returned
# a Net::SMTPUnknownError error. We then tried to send another message on the same
# connection after running `rset` the next message didn't raise any exceptions because
# net/smtp returns a '200 dummy reply code' and doesn't raise any exceptions.
#
def rset
@error_occurred = false
getok("RSET")
end
def rset_errors
@error_occurred = false
end
private
def tcp_socket(address, port)
TCPSocket.open(address, port, source_address)
end
class Response
def message
@string
end
end
end
end
================================================
FILE: config/initializers/trusted_proxies.rb
================================================
# frozen_string_literal: true
Rack::Request.ip_filter = lambda { |ip|
if Postal::Config.postal.trusted_proxies&.any? { |net| net.include?(ip) } ||
ip.match(/\A127\.0\.0\.1\Z|\A::1\Z|\Afd[0-9a-f]{2}:.+|\Alocalhost\Z|\Aunix\Z|\Aunix:/i)
true
else
false
end
}
================================================
FILE: config/initializers/wrap_parameters.rb
================================================
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# This file contains settings for ActionController::ParamsWrapper which
# is enabled by default.
# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
ActiveSupport.on_load(:action_controller) do
wrap_parameters format: [:json]
end
# To enable root element in JSON for ActiveRecord objects.
# ActiveSupport.on_load(:active_record) do
# self.include_root_in_json = true
# end
================================================
FILE: config/initializers/zeitwerk.rb
================================================
# frozen_string_literal: true
Rails.autoloaders.each do |autoloader|
# Ignore the message DB migrations directory as it doesn't follow
# Zeitwerk's conventions and is always loaded and executed in order.
autoloader.ignore(Rails.root.join("lib/postal/message_db/migrations"))
end
================================================
FILE: config/locales/en.yml
================================================
# Files in the config/locales directory are used for internationalization
# and are automatically loaded by Rails. If you want to use locales other
# than English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
# I18n.t 'hello'
#
# In views, this is aliased to just `t`:
#
# <%= t('hello') %>
#
# To use a different locale, set it with `I18n.locale`:
#
# I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# To learn more, please read the Rails Internationalization guide
# available at http://guides.rubyonrails.org/i18n.html.
en:
hello: "Hello world"
activerecord:
attributes:
organization:
permalink: Short name
server:
permalink: Short name
domain:
verification_method: Verify Method
http_endpoint:
url: URL
user:
email_address: E-Mail address
webhook:
url: URL
sign: Sign requests
server_statuses:
live: Live
development: Dev
suspended: Suspended
route_spam_modes:
quarantine: Spam will be quarantined
mark: Spam will be marked
http_endpoint_formats:
hash: Delivered as a hash
raw_message: Delivered as the raw message
http_endpoint_encodings:
body_as_json: Sent in the body as JSON
form_data: Sent as form data
webhook_events:
message_sent: An e-mail has been successfully delivered to its endpoint (either SMTP or HTTP).
message_delayed: An e-mail has been delayed due to an issue with the receiving endpoint. It will be retried automatically.
message_delivery_failed: An e-mail cannot be delivered to its endpoint. This is a permanent failure so it will no be retried.
message_held: An e-mail has been held in Postal. This will be because a limit has been reached or your server is in development mode.
message_bounced: We received a bounce message in response to an email which had previously been successfully sent.
message_link_clicked: A link in one of your outbound messages has been clicked.
message_loaded: A message you have sent has been loaded.
domain_dns_error: This will be triggered when we detect an issue with the DNS configuration for any domain for this server.
send_limit_approaching: This will be triggered when your mail server is approaching its send limit. It will only be sent once per hour.
send_limit_exceeded: This will be triggered when your mail server exceeded its send limit.
currencies:
gbp: GBP - Great British Pound (£)
usd: USD - United States Dollar ($)
eur: EUR - Euro (€)
route_modes:
accept: Accept message with no endpoint
hold: Accept message and put message in hold queue
bounce: Accept message and immediately send bounce to sender
reject: Do not accept any incoming messages
renewal_issues:
no_payment_card: You don't have a payment card on file
payment_declined: The payment for this service was declined
================================================
FILE: config/puma.rb
================================================
# frozen_string_literal: true
require_relative "../lib/postal/config"
threads_count = Postal::Config.web_server.max_threads
threads threads_count, threads_count
bind_address = ENV.fetch("BIND_ADDRESS", Postal::Config.web_server.default_bind_address)
bind_port = ENV.fetch("PORT", Postal::Config.web_server.default_port)
bind "tcp://#{bind_address}:#{bind_port}"
environment Postal::Config.rails.environment || "development"
prune_bundler
quiet false
================================================
FILE: config/routes.rb
================================================
# frozen_string_literal: true
Rails.application.routes.draw do
# Legacy API Routes
match "/api/v1/send/message" => "legacy_api/send#message", via: [:get, :post, :patch, :put]
match "/api/v1/send/raw" => "legacy_api/send#raw", via: [:get, :post, :patch, :put]
match "/api/v1/messages/message" => "legacy_api/messages#message", via: [:get, :post, :patch, :put]
match "/api/v1/messages/deliveries" => "legacy_api/messages#deliveries", via: [:get, :post, :patch, :put]
scope "org/:org_permalink", as: "organization" do
resources :domains, only: [:index, :new, :create, :destroy] do
match :verify, on: :member, via: [:get, :post]
get :setup, on: :member
post :check, on: :member
end
resources :servers, except: [:index] do
resources :domains, only: [:index, :new, :create, :destroy] do
match :verify, on: :member, via: [:get, :post]
get :setup, on: :member
post :check, on: :member
end
resources :track_domains do
post :toggle_ssl, on: :member
post :check, on: :member
end
resources :credentials
resources :routes
resources :http_endpoints
resources :smtp_endpoints
resources :address_endpoints
resources :ip_pool_rules
resources :messages do
get :incoming, on: :collection
get :outgoing, on: :collection
get :held, on: :collection
get :activity, on: :member
get :plain, on: :member
get :html, on: :member
get :html_raw, on: :member
get :attachments, on: :member
get :headers, on: :member
get :attachment, on: :member
get :download, on: :member
get :spam_checks, on: :member
post :retry, on: :member
post :cancel_hold, on: :member
get :suppressions, on: :collection
delete :remove_from_queue, on: :member
get :deliveries, on: :member
end
resources :webhooks do
get :history, on: :collection
get "history/:uuid", on: :collection, action: "history_request", as: "history_request"
end
get :limits, on: :member
get :retention, on: :member
get :queue, on: :member
get :spam, on: :member
get :delete, on: :member
get "help/outgoing" => "help#outgoing"
get "help/incoming" => "help#incoming"
get :advanced, on: :member
post :suspend, on: :member
post :unsuspend, on: :member
end
resources :ip_pool_rules
resources :ip_pools, controller: "organization_ip_pools" do
put :assignments, on: :collection
end
root "servers#index"
get "settings" => "organizations#edit"
patch "settings" => "organizations#update"
get "delete" => "organizations#delete"
delete "delete" => "organizations#destroy"
end
resources :organizations, except: [:index]
resources :users
resources :ip_pools do
resources :ip_addresses
end
get "settings" => "user#edit"
patch "settings" => "user#update"
post "persist" => "sessions#persist"
get "login" => "sessions#new"
post "login" => "sessions#create"
delete "logout" => "sessions#destroy"
match "login/reset" => "sessions#begin_password_reset", :via => [:get, :post]
match "login/reset/:token" => "sessions#finish_password_reset", :via => [:get, :post]
if Postal::Config.oidc.enabled?
get "auth/oidc/callback", to: "sessions#create_from_oidc"
end
get ".well-known/jwks.json" => "well_known#jwks"
get "ip" => "sessions#ip"
root "organizations#index"
end
================================================
FILE: config.ru
================================================
# frozen_string_literal: true
# This file is used by Rack-based servers to start the application.
require_relative "config/environment"
run Rails.application
================================================
FILE: db/migrate/20161003195209_create_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20141012174250)
class CreateAuthieSessions < ActiveRecord::Migration
def change
end
end
================================================
FILE: db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20141013115205)
class AddIndexesToAuthieSessions < ActiveRecord::Migration
def change
end
end
================================================
FILE: db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20150109144120)
class AddParentIdToAuthieSessions < ActiveRecord::Migration
def change
end
end
================================================
FILE: db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20150305135400)
class AddTwoFactorAuthFieldsToAuthie < ActiveRecord::Migration
def change
end
end
================================================
FILE: db/migrate/20170418200606_initial_schema.rb
================================================
# frozen_string_literal: true
class InitialSchema < ActiveRecord::Migration
def up
create_table "additional_route_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "route_id"
t.string "endpoint_type"
t.integer "endpoint_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "address_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.string "uuid"
t.string "address"
t.datetime "last_used_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "authie_sessions", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "token"
t.string "browser_id"
t.integer "user_id"
t.boolean "active", default: true
t.text "data", limit: 65_535
t.datetime "expires_at"
t.datetime "login_at"
t.string "login_ip"
t.datetime "last_activity_at"
t.string "last_activity_ip"
t.string "last_activity_path"
t.string "user_agent"
t.datetime "created_at"
t.datetime "updated_at"
t.string "user_type"
t.integer "parent_id"
t.datetime "two_factored_at"
t.string "two_factored_ip"
t.integer "requests", default: 0
t.datetime "password_seen_at"
t.string "token_hash"
t.index ["browser_id"], name: "index_authie_sessions_on_browser_id", length: { browser_id: 8 }, using: :btree
t.index ["token"], name: "index_authie_sessions_on_token", length: { token: 8 }, using: :btree
t.index ["user_id"], name: "index_authie_sessions_on_user_id", using: :btree
end
create_table "credentials", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.string "key"
t.string "type"
t.string "name"
t.text "options", limit: 65_535
t.datetime "last_used_at", precision: 6
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.boolean "hold", default: false
end
create_table "domains", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "verification_token"
t.string "verification_method"
t.datetime "verified_at"
t.text "dkim_private_key", limit: 65_535
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.datetime "dns_checked_at", precision: 6
t.string "spf_status"
t.string "spf_error"
t.string "dkim_status"
t.string "dkim_error"
t.string "mx_status"
t.string "mx_error"
t.string "return_path_status"
t.string "return_path_error"
t.boolean "outgoing", default: true
t.boolean "incoming", default: true
t.string "owner_type"
t.integer "owner_id"
t.string "dkim_identifier_string"
t.boolean "use_for_any"
t.index ["server_id"], name: "index_domains_on_server_id", using: :btree
t.index ["uuid"], name: "index_domains_on_uuid", length: { uuid: 8 }, using: :btree
end
create_table "http_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "url"
t.string "encoding"
t.string "format"
t.boolean "strip_replies", default: false
t.text "error", limit: 65_535
t.datetime "disabled_until", precision: 6
t.datetime "last_used_at", precision: 6
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.boolean "include_attachments", default: true
t.integer "timeout"
end
create_table "ip_addresses", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "ip_pool_id"
t.string "ipv4"
t.string "ipv6"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.string "hostname"
end
create_table "ip_pool_rules", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "uuid"
t.string "owner_type"
t.integer "owner_id"
t.integer "ip_pool_id"
t.text "from_text", limit: 65_535
t.text "to_text", limit: 65_535
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "ip_pools", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "name"
t.string "uuid"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.boolean "default", default: false
t.string "type"
t.index ["uuid"], name: "index_ip_pools_on_uuid", length: { uuid: 8 }, using: :btree
end
create_table "organization_ip_pools", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "organization_id"
t.integer "ip_pool_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "organization_users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "organization_id"
t.integer "user_id"
t.datetime "created_at", precision: 6
t.boolean "admin", default: false
t.boolean "all_servers", default: true
t.string "user_type"
end
create_table "organizations", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "uuid"
t.string "name"
t.string "permalink"
t.string "time_zone"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.integer "ip_pool_id"
t.integer "owner_id"
t.datetime "deleted_at", precision: 6
t.datetime "suspended_at", precision: 6
t.string "suspension_reason"
t.index ["permalink"], name: "index_organizations_on_permalink", length: { permalink: 8 }, using: :btree
t.index ["uuid"], name: "index_organizations_on_uuid", length: { uuid: 8 }, using: :btree
end
create_table "queued_messages", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.integer "message_id"
t.string "domain"
t.string "locked_by"
t.datetime "locked_at", precision: 6
t.datetime "retry_after"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.integer "ip_address_id"
t.integer "attempts", default: 0
t.integer "route_id"
t.boolean "manual", default: false
t.string "batch_key"
t.index ["domain"], name: "index_queued_messages_on_domain", length: { domain: 8 }, using: :btree
t.index ["message_id"], name: "index_queued_messages_on_message_id", using: :btree
t.index ["server_id"], name: "index_queued_messages_on_server_id", using: :btree
end
create_table "routes", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "uuid"
t.integer "server_id"
t.integer "domain_id"
t.integer "endpoint_id"
t.string "endpoint_type"
t.string "name"
t.string "spam_mode"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.string "token"
t.string "mode"
t.index ["token"], name: "index_routes_on_token", length: { token: 6 }, using: :btree
end
create_table "servers", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "organization_id"
t.string "uuid"
t.string "name"
t.string "mode"
t.integer "ip_pool_id"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.string "permalink"
t.integer "send_limit"
t.datetime "deleted_at", precision: 6
t.integer "message_retention_days"
t.integer "raw_message_retention_days"
t.integer "raw_message_retention_size"
t.boolean "allow_sender", default: false
t.string "token"
t.datetime "send_limit_approaching_at", precision: 6
t.datetime "send_limit_approaching_notified_at", precision: 6
t.datetime "send_limit_exceeded_at", precision: 6
t.datetime "send_limit_exceeded_notified_at", precision: 6
t.decimal "spam_threshold", precision: 8, scale: 2
t.decimal "spam_failure_threshold", precision: 8, scale: 2
t.string "postmaster_address"
t.datetime "suspended_at", precision: 6
t.decimal "outbound_spam_threshold", precision: 8, scale: 2
t.text "domains_not_to_click_track", limit: 65_535
t.string "suspension_reason"
t.boolean "log_smtp_data", default: false
t.index ["organization_id"], name: "index_servers_on_organization_id", using: :btree
t.index ["permalink"], name: "index_servers_on_permalink", length: { permalink: 6 }, using: :btree
t.index ["token"], name: "index_servers_on_token", length: { token: 6 }, using: :btree
t.index ["uuid"], name: "index_servers_on_uuid", length: { uuid: 8 }, using: :btree
end
create_table "smtp_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "hostname"
t.string "ssl_mode"
t.integer "port"
t.text "error", limit: 65_535
t.datetime "disabled_until", precision: 6
t.datetime "last_used_at", precision: 6
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
end
create_table "statistics", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.bigint "total_messages", default: 0
t.bigint "total_outgoing", default: 0
t.bigint "total_incoming", default: 0
end
create_table "track_certificates", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "domain"
t.text "certificate", limit: 65_535
t.text "intermediaries", limit: 65_535
t.text "key", limit: 65_535
t.datetime "expires_at"
t.datetime "renew_after"
t.string "verification_path"
t.string "verification_string"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["domain"], name: "index_track_certificates_on_domain", length: { domain: 8 }, using: :btree
end
create_table "track_domains", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "uuid"
t.integer "server_id"
t.integer "domain_id"
t.string "name"
t.datetime "dns_checked_at"
t.string "dns_status"
t.string "dns_error"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "ssl_enabled", default: true
t.boolean "track_clicks", default: true
t.boolean "track_loads", default: true
t.text "excluded_click_domains", limit: 65_535
end
create_table "user_invites", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "uuid"
t.string "email_address"
t.datetime "expires_at", precision: 6
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.index ["uuid"], name: "index_user_invites_on_uuid", length: { uuid: 12 }, using: :btree
end
create_table "users", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.string "uuid"
t.string "first_name"
t.string "last_name"
t.string "email_address"
t.string "password_digest"
t.string "time_zone"
t.string "email_verification_token"
t.datetime "email_verified_at"
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.string "password_reset_token"
t.datetime "password_reset_token_valid_until"
t.boolean "admin", default: false
t.index ["email_address"], name: "index_users_on_email_address", length: { email_address: 8 }, using: :btree
t.index ["uuid"], name: "index_users_on_uuid", length: { uuid: 8 }, using: :btree
end
create_table "webhook_events", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "webhook_id"
t.string "event"
t.datetime "created_at", precision: 6
t.index ["webhook_id"], name: "index_webhook_events_on_webhook_id", using: :btree
end
create_table "webhook_requests", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.integer "webhook_id"
t.string "url"
t.string "event"
t.string "uuid"
t.text "payload", limit: 65_535
t.integer "attempts", default: 0
t.datetime "retry_after", precision: 6
t.text "error", limit: 65_535
t.datetime "created_at", precision: 6
end
create_table "webhooks", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "url"
t.datetime "last_used_at"
t.boolean "all_events", default: false
t.boolean "enabled", default: true
t.boolean "sign", default: true
t.datetime "created_at", precision: 6
t.datetime "updated_at", precision: 6
t.index ["server_id"], name: "index_webhooks_on_server_id", using: :btree
end
end
end
================================================
FILE: db/migrate/20170421195414_add_token_hashes_to_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20170417170000)
class AddTokenHashesToAuthieSessions < ActiveRecord::Migration
def change
end
end
================================================
FILE: db/migrate/20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20170421174100)
class AddIndexToTokenHashesOnAuthieSessions < ActiveRecord::Migration
def change
add_index :authie_sessions, :token_hash, length: 8
end
end
================================================
FILE: db/migrate/20170428153353_remove_type_from_ip_pools.rb
================================================
# frozen_string_literal: true
class RemoveTypeFromIPPools < ActiveRecord::Migration[5.0]
def change
remove_column :ip_pools, :type, :string
end
end
================================================
FILE: db/migrate/20180216114344_add_host_to_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20180215152200)
class AddHostToAuthieSessions < ActiveRecord::Migration[4.2]
def change
add_column :authie_sessions, :host, :string
end
end
================================================
FILE: db/migrate/20200717083943_add_uuid_to_credentials.rb
================================================
# frozen_string_literal: true
class AddUUIDToCredentials < ActiveRecord::Migration[5.2]
def change
add_column :credentials, :uuid, :string
Credential.find_each do |c|
c.update_column(:uuid, SecureRandom.uuid)
end
end
end
================================================
FILE: db/migrate/20210727210551_add_priority_to_ip_addresses.rb
================================================
# frozen_string_literal: true
class AddPriorityToIPAddresses < ActiveRecord::Migration[5.2]
def change
add_column :ip_addresses, :priority, :integer
IPAddress.where(priority: nil).update_all(priority: 100)
end
end
================================================
FILE: db/migrate/20240206173036_add_privacy_mode_to_servers.rb
================================================
# frozen_string_literal: true
class AddPrivacyModeToServers < ActiveRecord::Migration[6.1]
def change
add_column :servers, :privacy_mode, :boolean, default: false
end
end
================================================
FILE: db/migrate/20240213165450_create_worker_roles.rb
================================================
# frozen_string_literal: true
class CreateWorkerRoles < ActiveRecord::Migration[6.1]
def change
create_table :worker_roles do |t|
t.string :role
t.string :worker
t.datetime :acquired_at
t.index :role, unique: true
end
end
end
================================================
FILE: db/migrate/20240213171830_create_scheduled_tasks.rb
================================================
# frozen_string_literal: true
class CreateScheduledTasks < ActiveRecord::Migration[6.1]
def change
create_table :scheduled_tasks do |t|
t.string :name
t.datetime :next_run_after
t.index :name, unique: true
end
end
end
================================================
FILE: db/migrate/20240214132253_add_lock_fields_to_webhook_requests.rb
================================================
# frozen_string_literal: true
class AddLockFieldsToWebhookRequests < ActiveRecord::Migration[6.1]
def change
add_column :webhook_requests, :locked_by, :string
add_column :webhook_requests, :locked_at, :datetime
add_index :webhook_requests, :locked_by
end
end
================================================
FILE: db/migrate/20240223141500_add_two_factor_required_to_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20220502180100)
class AddTwoFactorRequiredToSessions < ActiveRecord::Migration[6.1]
def change
add_column :authie_sessions, :skip_two_factor, :boolean, default: false
end
end
================================================
FILE: db/migrate/20240223141501_add_countries_to_authie_sessions.authie.rb
================================================
# frozen_string_literal: true
# This migration comes from authie (originally 20230627165500)
class AddCountriesToAuthieSessions < ActiveRecord::Migration[6.1]
def change
add_column :authie_sessions, :login_ip_country, :string
add_column :authie_sessions, :two_factored_ip_country, :string
add_column :authie_sessions, :last_activity_ip_country, :string
end
end
================================================
FILE: db/migrate/20240311205229_add_oidc_fields_to_user.rb
================================================
# frozen_string_literal: true
class AddOIDCFieldsToUser < ActiveRecord::Migration[7.0]
def change
add_column :users, :oidc_uid, :string
add_column :users, :oidc_issuer, :string
end
end
================================================
FILE: db/schema.rb
================================================
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do
create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "route_id"
t.string "endpoint_type"
t.integer "endpoint_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "address_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.string "uuid"
t.string "address"
t.datetime "last_used_at", precision: nil
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "authie_sessions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "token"
t.string "browser_id"
t.integer "user_id"
t.boolean "active", default: true
t.text "data"
t.datetime "expires_at", precision: nil
t.datetime "login_at", precision: nil
t.string "login_ip"
t.datetime "last_activity_at", precision: nil
t.string "last_activity_ip"
t.string "last_activity_path"
t.string "user_agent"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.string "user_type"
t.integer "parent_id"
t.datetime "two_factored_at", precision: nil
t.string "two_factored_ip"
t.integer "requests", default: 0
t.datetime "password_seen_at", precision: nil
t.string "token_hash"
t.string "host"
t.boolean "skip_two_factor", default: false
t.string "login_ip_country"
t.string "two_factored_ip_country"
t.string "last_activity_ip_country"
t.index ["browser_id"], name: "index_authie_sessions_on_browser_id", length: 8
t.index ["token"], name: "index_authie_sessions_on_token", length: 8
t.index ["token_hash"], name: "index_authie_sessions_on_token_hash", length: 8
t.index ["user_id"], name: "index_authie_sessions_on_user_id"
end
create_table "credentials", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.string "key"
t.string "type"
t.string "name"
t.text "options"
t.datetime "last_used_at"
t.datetime "created_at"
t.datetime "updated_at"
t.boolean "hold", default: false
t.string "uuid"
end
create_table "domains", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "verification_token"
t.string "verification_method"
t.datetime "verified_at", precision: nil
t.text "dkim_private_key"
t.datetime "created_at"
t.datetime "updated_at"
t.datetime "dns_checked_at"
t.string "spf_status"
t.string "spf_error"
t.string "dkim_status"
t.string "dkim_error"
t.string "mx_status"
t.string "mx_error"
t.string "return_path_status"
t.string "return_path_error"
t.boolean "outgoing", default: true
t.boolean "incoming", default: true
t.string "owner_type"
t.integer "owner_id"
t.string "dkim_identifier_string"
t.boolean "use_for_any"
t.index ["server_id"], name: "index_domains_on_server_id"
t.index ["uuid"], name: "index_domains_on_uuid", length: 8
end
create_table "http_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "url"
t.string "encoding"
t.string "format"
t.boolean "strip_replies", default: false
t.text "error"
t.datetime "disabled_until"
t.datetime "last_used_at"
t.datetime "created_at"
t.datetime "updated_at"
t.boolean "include_attachments", default: true
t.integer "timeout"
end
create_table "ip_addresses", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "ip_pool_id"
t.string "ipv4"
t.string "ipv6"
t.datetime "created_at"
t.datetime "updated_at"
t.string "hostname"
t.integer "priority"
end
create_table "ip_pool_rules", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "uuid"
t.string "owner_type"
t.integer "owner_id"
t.integer "ip_pool_id"
t.text "from_text"
t.text "to_text"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "ip_pools", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "name"
t.string "uuid"
t.datetime "created_at"
t.datetime "updated_at"
t.boolean "default", default: false
t.index ["uuid"], name: "index_ip_pools_on_uuid", length: 8
end
create_table "organization_ip_pools", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "organization_id"
t.integer "ip_pool_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "organization_users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "organization_id"
t.integer "user_id"
t.datetime "created_at"
t.boolean "admin", default: false
t.boolean "all_servers", default: true
t.string "user_type"
end
create_table "organizations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "uuid"
t.string "name"
t.string "permalink"
t.string "time_zone"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "ip_pool_id"
t.integer "owner_id"
t.datetime "deleted_at"
t.datetime "suspended_at"
t.string "suspension_reason"
t.index ["permalink"], name: "index_organizations_on_permalink", length: 8
t.index ["uuid"], name: "index_organizations_on_uuid", length: 8
end
create_table "queued_messages", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.integer "message_id"
t.string "domain"
t.string "locked_by"
t.datetime "locked_at"
t.datetime "retry_after", precision: nil
t.datetime "created_at"
t.datetime "updated_at"
t.integer "ip_address_id"
t.integer "attempts", default: 0
t.integer "route_id"
t.boolean "manual", default: false
t.string "batch_key"
t.index ["domain"], name: "index_queued_messages_on_domain", length: 8
t.index ["message_id"], name: "index_queued_messages_on_message_id"
t.index ["server_id"], name: "index_queued_messages_on_server_id"
end
create_table "routes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "uuid"
t.integer "server_id"
t.integer "domain_id"
t.integer "endpoint_id"
t.string "endpoint_type"
t.string "name"
t.string "spam_mode"
t.datetime "created_at"
t.datetime "updated_at"
t.string "token"
t.string "mode"
t.index ["token"], name: "index_routes_on_token", length: 6
end
create_table "scheduled_tasks", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "name"
t.datetime "next_run_after", precision: nil
t.index ["name"], name: "index_scheduled_tasks_on_name", unique: true
end
create_table "servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "organization_id"
t.string "uuid"
t.string "name"
t.string "mode"
t.integer "ip_pool_id"
t.datetime "created_at"
t.datetime "updated_at"
t.string "permalink"
t.integer "send_limit"
t.datetime "deleted_at"
t.integer "message_retention_days"
t.integer "raw_message_retention_days"
t.integer "raw_message_retention_size"
t.boolean "allow_sender", default: false
t.string "token"
t.datetime "send_limit_approaching_at"
t.datetime "send_limit_approaching_notified_at"
t.datetime "send_limit_exceeded_at"
t.datetime "send_limit_exceeded_notified_at"
t.decimal "spam_threshold", precision: 8, scale: 2
t.decimal "spam_failure_threshold", precision: 8, scale: 2
t.string "postmaster_address"
t.datetime "suspended_at"
t.decimal "outbound_spam_threshold", precision: 8, scale: 2
t.text "domains_not_to_click_track"
t.string "suspension_reason"
t.boolean "log_smtp_data", default: false
t.boolean "privacy_mode", default: false
t.index ["organization_id"], name: "index_servers_on_organization_id"
t.index ["permalink"], name: "index_servers_on_permalink", length: 6
t.index ["token"], name: "index_servers_on_token", length: 6
t.index ["uuid"], name: "index_servers_on_uuid", length: 8
end
create_table "smtp_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "hostname"
t.string "ssl_mode"
t.integer "port"
t.text "error"
t.datetime "disabled_until"
t.datetime "last_used_at"
t.datetime "created_at"
t.datetime "updated_at"
end
create_table "statistics", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "total_messages", default: 0
t.bigint "total_outgoing", default: 0
t.bigint "total_incoming", default: 0
end
create_table "track_certificates", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "domain"
t.text "certificate"
t.text "intermediaries"
t.text "key"
t.datetime "expires_at", precision: nil
t.datetime "renew_after", precision: nil
t.string "verification_path"
t.string "verification_string"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["domain"], name: "index_track_certificates_on_domain", length: 8
end
create_table "track_domains", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "uuid"
t.integer "server_id"
t.integer "domain_id"
t.string "name"
t.datetime "dns_checked_at", precision: nil
t.string "dns_status"
t.string "dns_error"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.boolean "ssl_enabled", default: true
t.boolean "track_clicks", default: true
t.boolean "track_loads", default: true
t.text "excluded_click_domains"
end
create_table "user_invites", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "uuid"
t.string "email_address"
t.datetime "expires_at"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["uuid"], name: "index_user_invites_on_uuid", length: 12
end
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "uuid"
t.string "first_name"
t.string "last_name"
t.string "email_address"
t.string "password_digest"
t.string "time_zone"
t.string "email_verification_token"
t.datetime "email_verified_at", precision: nil
t.datetime "created_at"
t.datetime "updated_at"
t.string "password_reset_token"
t.datetime "password_reset_token_valid_until", precision: nil
t.boolean "admin", default: false
t.string "oidc_uid"
t.string "oidc_issuer"
t.index ["email_address"], name: "index_users_on_email_address", length: 8
t.index ["uuid"], name: "index_users_on_uuid", length: 8
end
create_table "webhook_events", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "webhook_id"
t.string "event"
t.datetime "created_at"
t.index ["webhook_id"], name: "index_webhook_events_on_webhook_id"
end
create_table "webhook_requests", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.integer "webhook_id"
t.string "url"
t.string "event"
t.string "uuid"
t.text "payload"
t.integer "attempts", default: 0
t.datetime "retry_after"
t.text "error"
t.datetime "created_at"
t.string "locked_by"
t.datetime "locked_at", precision: nil
t.index ["locked_by"], name: "index_webhook_requests_on_locked_by"
end
create_table "webhooks", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "server_id"
t.string "uuid"
t.string "name"
t.string "url"
t.datetime "last_used_at", precision: nil
t.boolean "all_events", default: false
t.boolean "enabled", default: true
t.boolean "sign", default: true
t.datetime "created_at"
t.datetime "updated_at"
t.index ["server_id"], name: "index_webhooks_on_server_id"
end
create_table "worker_roles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "role"
t.string "worker"
t.datetime "acquired_at", precision: nil
t.index ["role"], name: "index_worker_roles_on_role", unique: true
end
end
================================================
FILE: db/seeds.rb
================================================
# frozen_string_literal: true
# Seeds go here...
================================================
FILE: doc/config/configuration.md
================================================
# Configuring Postal
Postal can be configured in two ways: using a YAML-based configuration file or through environment variables.
If you choose to use environment variables, you don't need to provide a config file. A full list of environment variables is available in the `environment-variables.md` file in this directory.
To use a configuration file, the `POSTAL_CONFIG_FILE_PATH` environment variable will dictate where Postal will look for the config file. An example YAML file containing all available configuration is provided in the `yaml.yml` file in this directory. Remember to include the `version: 2` key/value in your configuration file.
## Development
When developing with Postal, you can configure the application by placing a configuration file in `config/postal/postal.yml`. Alternatively, you can use environment variables by placing configuration in `.env` in the root of the application.
### Running tests
By default, tests will use the `config/postal/postal.test.yml` configuration file and the `.env.test` environment file.
## Containers
Within a container, Postal will for a config file in `/config/postal.yml` unless overriden by the `POSTAL_CONFIG_FILE_PATH` environment variable.
## Ports & Bind Addresses
The web & SMTP server listen on ports and addresses. The defaults for these can be set through configuration however, if you're running multiple instances of these on a single host you will need to specify different ports for each one.
You can use the `PORT` and `BIND_ADDRESS` environment variables to provide instance-specific values for these processes.
Additionally, `HEALTH_SERVER_PORT` and `HEALTH_SERVER_BIND_ADDRESS` can be used to set the port/address to use for running the health server alongside other processes.
## Legacy configuration
Legacy configuration files from Postal v1 and v2 are still supported. If you wish to use a new configuration option that is not available in the legacy format, you will need to upgrade the file to version 2.
================================================
FILE: doc/config/environment-variables.md
================================================
# Environment Variables
This document contains all the environment variables which are available for this application.
| Name | Type | Description | Default |
| ---- | ---- | ----------- | ------- |
| `POSTAL_WEB_HOSTNAME` | String | The hostname that the Postal web interface runs on | postal.example.com |
| `POSTAL_WEB_PROTOCOL` | String | The HTTP protocol to use for the Postal web interface | https |
| `POSTAL_SMTP_HOSTNAME` | String | The hostname that the Postal SMTP server runs on | postal.example.com |
| `POSTAL_USE_IP_POOLS` | Boolean | Should IP pools be enabled for this installation? | false |
| `POSTAL_DEFAULT_MAXIMUM_DELIVERY_ATTEMPTS` | Integer | The maximum number of delivery attempts | 18 |
| `POSTAL_DEFAULT_MAXIMUM_HOLD_EXPIRY_DAYS` | Integer | The number of days to hold a message before they will be expired | 7 |
| `POSTAL_DEFAULT_SUPPRESSION_LIST_AUTOMATIC_REMOVAL_DAYS` | Integer | The number of days an address will remain in a suppression list before being removed | 30 |
| `POSTAL_DEFAULT_SPAM_THRESHOLD` | Integer | The default threshold at which a message should be treated as spam | 5 |
| `POSTAL_DEFAULT_SPAM_FAILURE_THRESHOLD` | Integer | The default threshold at which a message should be treated as spam failure | 20 |
| `POSTAL_USE_LOCAL_NS_FOR_DOMAIN_VERIFICATION` | Boolean | Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers. | false |
| `POSTAL_USE_RESENT_SENDER_HEADER` | Boolean | Append a Resend-Sender header to all outgoing e-mails | true |
| `POSTAL_SIGNING_KEY_PATH` | String | Path to the private key used for signing | $config-file-root/signing.key |
| `POSTAL_SMTP_RELAYS` | Array of strings | An array of SMTP relays in the format of smtp://host:port | [] |
| `POSTAL_TRUSTED_PROXIES` | Array of strings | An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses) | [] |
| `POSTAL_QUEUED_MESSAGE_LOCK_STALE_DAYS` | Integer | The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried. | 1 |
| `POSTAL_BATCH_QUEUED_MESSAGES` | Boolean | When enabled queued messages will be de-queued in batches based on their destination | true |
| `WEB_SERVER_DEFAULT_PORT` | Integer | The default port the web server should listen on unless overriden by the PORT environment variable | 5000 |
| `WEB_SERVER_DEFAULT_BIND_ADDRESS` | String | The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable | 127.0.0.1 |
| `WEB_SERVER_MAX_THREADS` | Integer | The maximum number of threads which can be used by the web server | 5 |
| `WORKER_DEFAULT_HEALTH_SERVER_PORT` | Integer | The default port for the worker health server to listen on | 9090 |
| `WORKER_DEFAULT_HEALTH_SERVER_BIND_ADDRESS` | String | The default bind address for the worker health server to listen on | 127.0.0.1 |
| `WORKER_THREADS` | Integer | The number of threads to execute within each worker | 2 |
| `MAIN_DB_HOST` | String | Hostname for the main MariaDB server | localhost |
| `MAIN_DB_PORT` | Integer | The MariaDB port to connect to | 3306 |
| `MAIN_DB_USERNAME` | String | The MariaDB username | postal |
| `MAIN_DB_PASSWORD` | String | The MariaDB password | |
| `MAIN_DB_DATABASE` | String | The MariaDB database name | postal |
| `MAIN_DB_POOL_SIZE` | Integer | The maximum size of the MariaDB connection pool | 5 |
| `MAIN_DB_ENCODING` | String | The encoding to use when connecting to the MariaDB database | utf8mb4 |
| `MESSAGE_DB_HOST` | String | Hostname for the MariaDB server which stores the mail server databases | localhost |
| `MESSAGE_DB_PORT` | Integer | The MariaDB port to connect to | 3306 |
| `MESSAGE_DB_USERNAME` | String | The MariaDB username | postal |
| `MESSAGE_DB_PASSWORD` | String | The MariaDB password | |
| `MESSAGE_DB_ENCODING` | String | The encoding to use when connecting to the MariaDB database | utf8mb4 |
| `MESSAGE_DB_DATABASE_NAME_PREFIX` | String | The MariaDB prefix to add to database names | postal |
| `LOGGING_RAILS_LOG_ENABLED` | Boolean | Enable the default Rails logger | false |
| `LOGGING_SENTRY_DSN` | String | A DSN which should be used to report exceptions to Sentry | |
| `LOGGING_ENABLED` | Boolean | Enable the Postal logger to log to STDOUT | true |
| `LOGGING_HIGHLIGHTING_ENABLED` | Boolean | Enable highlighting of log lines | false |
| `GELF_HOST` | String | GELF-capable host to send logs to | |
| `GELF_PORT` | Integer | GELF port to send logs to | 12201 |
| `GELF_FACILITY` | String | The facility name to add to all log entries sent to GELF | postal |
| `SMTP_SERVER_DEFAULT_PORT` | Integer | The default port the SMTP server should listen on unless overriden by the PORT environment variable | 25 |
| `SMTP_SERVER_DEFAULT_BIND_ADDRESS` | String | The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable | :: |
| `SMTP_SERVER_DEFAULT_HEALTH_SERVER_PORT` | Integer | The default port for the SMTP server health server to listen on | 9091 |
| `SMTP_SERVER_DEFAULT_HEALTH_SERVER_BIND_ADDRESS` | String | The default bind address for the SMTP server health server to listen on | 127.0.0.1 |
| `SMTP_SERVER_TLS_ENABLED` | Boolean | Enable TLS for the SMTP server (requires certificate) | false |
| `SMTP_SERVER_TLS_CERTIFICATE_PATH` | String | The path to the SMTP server's TLS certificate | $config-file-root/smtp.cert |
| `SMTP_SERVER_TLS_PRIVATE_KEY_PATH` | String | The path to the SMTP server's TLS private key | $config-file-root/smtp.key |
| `SMTP_SERVER_TLS_CIPHERS` | String | Override ciphers to use for SSL | |
| `SMTP_SERVER_SSL_VERSION` | String | The SSL versions which are supported | SSLv23 |
| `SMTP_SERVER_PROXY_PROTOCOL` | Boolean | Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only) | false |
| `SMTP_SERVER_LOG_CONNECTIONS` | Boolean | Enable connection logging | false |
| `SMTP_SERVER_MAX_MESSAGE_SIZE` | Integer | The maximum message size to accept from the SMTP server (in MB) | 14 |
| `SMTP_SERVER_LOG_IP_ADDRESS_EXCLUSION_MATCHER` | String | A regular expression to use to exclude connections from logging | |
| `DNS_MX_RECORDS` | Array of strings | The names of the default MX records | ["mx1.postal.example.com", "mx2.postal.example.com"] |
| `DNS_SPF_INCLUDE` | String | The location of the SPF record | spf.postal.example.com |
| `DNS_RETURN_PATH_DOMAIN` | String | The return path hostname | rp.postal.example.com |
| `DNS_ROUTE_DOMAIN` | String | The domain to use for hosting route-specific addresses | routes.postal.example.com |
| `DNS_TRACK_DOMAIN` | String | The CNAME which tracking domains should be pointed to | track.postal.example.com |
| `DNS_HELO_HOSTNAME` | String | The hostname to use in HELO/EHLO when connecting to external SMTP servers | |
| `DNS_DKIM_IDENTIFIER` | String | The identifier to use for DKIM keys in DNS records | postal |
| `DNS_DOMAIN_VERIFY_PREFIX` | String | The prefix to add before TXT record verification string | postal-verification |
| `DNS_CUSTOM_RETURN_PATH_PREFIX` | String | The domain to use on external domains which points to the Postal return path domain | psrp |
| `DNS_TIMEOUT` | Integer | The timeout to wait for DNS resolution | 5 |
| `DNS_RESOLV_CONF_PATH` | String | The path to the resolv.conf file containing addresses for local nameservers | /etc/resolv.conf |
| `SMTP_HOST` | String | The hostname to send application-level e-mails to | 127.0.0.1 |
| `SMTP_PORT` | Integer | The port number to send application-level e-mails to | 25 |
| `SMTP_USERNAME` | String | The username to use when authentication to the SMTP server | |
| `SMTP_PASSWORD` | String | The password to use when authentication to the SMTP server | |
| `SMTP_AUTHENTICATION_TYPE` | String | The type of authentication to use | login |
| `SMTP_ENABLE_STARTTLS` | Boolean | Use STARTTLS when connecting to the SMTP server and fail if unsupported | false |
| `SMTP_ENABLE_STARTTLS_AUTO` | Boolean | Detects if STARTTLS is enabled in the SMTP server and starts to use it | true |
| `SMTP_OPENSSL_VERIFY_MODE` | String | When using TLS, you can set how OpenSSL checks the certificate. Use 'none' for no certificate checking | peer |
| `SMTP_FROM_NAME` | String | The name to use as the from name outgoing emails from Postal | Postal |
| `SMTP_FROM_ADDRESS` | String | The e-mail to use as the from address outgoing emails from Postal | postal@example.com |
| `RAILS_ENVIRONMENT` | String | The Rails environment to run the application in | production |
| `RAILS_SECRET_KEY` | String | The secret key used to sign and encrypt cookies and session data in the application | |
| `RSPAMD_ENABLED` | Boolean | Enable rspamd for message inspection | false |
| `RSPAMD_HOST` | String | The hostname of the rspamd server | 127.0.0.1 |
| `RSPAMD_PORT` | Integer | The port of the rspamd server | 11334 |
| `RSPAMD_SSL` | Boolean | Enable SSL for the rspamd connection | false |
| `RSPAMD_PASSWORD` | String | The password for the rspamd server | |
| `RSPAMD_FLAGS` | String | Any flags for the rspamd server | |
| `SPAMD_ENABLED` | Boolean | Enable SpamAssassin for message inspection | false |
| `SPAMD_HOST` | String | The hostname for the SpamAssassin server | 127.0.0.1 |
| `SPAMD_PORT` | Integer | The port of the SpamAssassin server | 783 |
| `CLAMAV_ENABLED` | Boolean | Enable ClamAV for message inspection | false |
| `CLAMAV_HOST` | String | The host of the ClamAV server | 127.0.0.1 |
| `CLAMAV_PORT` | Integer | The port of the ClamAV server | 2000 |
| `SMTP_CLIENT_OPEN_TIMEOUT` | Integer | The open timeout for outgoing SMTP connections | 30 |
| `SMTP_CLIENT_READ_TIMEOUT` | Integer | The read timeout for outgoing SMTP connections | 30 |
| `MIGRATION_WAITER_ENABLED` | Boolean | Wait for all migrations to run before starting a process | false |
| `MIGRATION_WAITER_ATTEMPTS` | Integer | The number of attempts to try waiting for migrations to complete before start | 120 |
| `MIGRATION_WAITER_SLEEP_TIME` | Integer | The number of seconds to wait between each migration check | 2 |
| `OIDC_ENABLED` | Boolean | Enable OIDC authentication | false |
| `OIDC_LOCAL_AUTHENTICATION_ENABLED` | Boolean | When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available. | true |
| `OIDC_NAME` | String | The name of the OIDC provider as shown in the UI | OIDC Provider |
| `OIDC_ISSUER` | String | The OIDC issuer URL | |
| `OIDC_IDENTIFIER` | String | The client ID for OIDC | |
| `OIDC_SECRET` | String | The client secret for OIDC | |
| `OIDC_SCOPES` | Array of strings | Scopes to request from the OIDC server. | ["openid", "email"] |
| `OIDC_UID_FIELD` | String | The field to use to determine the user's UID | sub |
| `OIDC_EMAIL_ADDRESS_FIELD` | String | The field to use to determine the user's email address | email |
| `OIDC_NAME_FIELD` | String | The field to use to determine the user's name | name |
| `OIDC_DISCOVERY` | Boolean | Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer | true |
| `OIDC_AUTHORIZATION_ENDPOINT` | String | The authorize endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_TOKEN_ENDPOINT` | String | The token endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_USERINFO_ENDPOINT` | String | The user info endpoint on the authorization server (only used when discovery is false) | |
| `OIDC_JWKS_URI` | String | The JWKS endpoint on the authorization server (only used when discovery is false) | |
================================================
FILE: doc/config/yaml.yml
================================================
version: 2
postal:
# The hostname that the Postal web interface runs on
web_hostname: postal.example.com
# The HTTP protocol to use for the Postal web interface
web_protocol: https
# The hostname that the Postal SMTP server runs on
smtp_hostname: postal.example.com
# Should IP pools be enabled for this installation?
use_ip_pools: false
# The maximum number of delivery attempts
default_maximum_delivery_attempts: 18
# The number of days to hold a message before they will be expired
default_maximum_hold_expiry_days: 7
# The number of days an address will remain in a suppression list before being removed
default_suppression_list_automatic_removal_days: 30
# The default threshold at which a message should be treated as spam
default_spam_threshold: 5
# The default threshold at which a message should be treated as spam failure
default_spam_failure_threshold: 20
# Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers.
use_local_ns_for_domain_verification: false
# Append a Resend-Sender header to all outgoing e-mails
use_resent_sender_header: true
# Path to the private key used for signing
signing_key_path: $config-file-root/signing.key
# An array of SMTP relays in the format of smtp://host:port
smtp_relays: []
# An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)
trusted_proxies: []
# The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried.
queued_message_lock_stale_days: 1
# When enabled queued messages will be de-queued in batches based on their destination
batch_queued_messages: true
web_server:
# The default port the web server should listen on unless overriden by the PORT environment variable
default_port: 5000
# The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable
default_bind_address: 127.0.0.1
# The maximum number of threads which can be used by the web server
max_threads: 5
worker:
# The default port for the worker health server to listen on
default_health_server_port: 9090
# The default bind address for the worker health server to listen on
default_health_server_bind_address: 127.0.0.1
# The number of threads to execute within each worker
threads: 2
main_db:
# Hostname for the main MariaDB server
host: localhost
# The MariaDB port to connect to
port: 3306
# The MariaDB username
username: postal
# The MariaDB password
password:
# The MariaDB database name
database: postal
# The maximum size of the MariaDB connection pool
pool_size: 5
# The encoding to use when connecting to the MariaDB database
encoding: utf8mb4
message_db:
# Hostname for the MariaDB server which stores the mail server databases
host: localhost
# The MariaDB port to connect to
port: 3306
# The MariaDB username
username: postal
# The MariaDB password
password:
# The encoding to use when connecting to the MariaDB database
encoding: utf8mb4
# The MariaDB prefix to add to database names
database_name_prefix: postal
logging:
# Enable the default Rails logger
rails_log_enabled: false
# A DSN which should be used to report exceptions to Sentry
sentry_dsn:
# Enable the Postal logger to log to STDOUT
enabled: true
# Enable highlighting of log lines
highlighting_enabled: false
gelf:
# GELF-capable host to send logs to
host:
# GELF port to send logs to
port: 12201
# The facility name to add to all log entries sent to GELF
facility: postal
smtp_server:
# The default port the SMTP server should listen on unless overriden by the PORT environment variable
default_port: 25
# The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable
default_bind_address: ::
# The default port for the SMTP server health server to listen on
default_health_server_port: 9091
# The default bind address for the SMTP server health server to listen on
default_health_server_bind_address: 127.0.0.1
# Enable TLS for the SMTP server (requires certificate)
tls_enabled: false
# The path to the SMTP server's TLS certificate
tls_certificate_path: $config-file-root/smtp.cert
# The path to the SMTP server's TLS private key
tls_private_key_path: $config-file-root/smtp.key
# Override ciphers to use for SSL
tls_ciphers:
# The SSL versions which are supported
ssl_version: SSLv23
# Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only)
proxy_protocol: false
# Enable connection logging
log_connections: false
# The maximum message size to accept from the SMTP server (in MB)
max_message_size: 14
# A regular expression to use to exclude connections from logging
log_ip_address_exclusion_matcher:
dns:
# The names of the default MX records
mx_records:
- mx1.postal.example.com
- mx2.postal.example.com
# The location of the SPF record
spf_include: spf.postal.example.com
# The return path hostname
return_path_domain: rp.postal.example.com
# The domain to use for hosting route-specific addresses
route_domain: routes.postal.example.com
# The CNAME which tracking domains should be pointed to
track_domain: track.postal.example.com
# The hostname to use in HELO/EHLO when connecting to external SMTP servers
helo_hostname:
# The identifier to use for DKIM keys in DNS records
dkim_identifier: postal
# The prefix to add before TXT record verification string
domain_verify_prefix: postal-verification
# The domain to use on external domains which points to the Postal return path domain
custom_return_path_prefix: psrp
# The timeout to wait for DNS resolution
timeout: 5
# The path to the resolv.conf file containing addresses for local nameservers
resolv_conf_path: /etc/resolv.conf
smtp:
# The hostname to send application-level e-mails to
host: 127.0.0.1
# The port number to send application-level e-mails to
port: 25
# The username to use when authentication to the SMTP server
username:
# The password to use when authentication to the SMTP server
password:
# The type of authentication to use
authentication_type: login
# Use STARTTLS when connecting to the SMTP server and fail if unsupported
enable_starttls: false
# Detects if STARTTLS is enabled in the SMTP server and starts to use it
enable_starttls_auto: true
# When using TLS, you can set how OpenSSL checks the certificate. Use 'none' for no certificate checking
openssl_verify_mode: peer
# The name to use as the from name outgoing emails from Postal
from_name: Postal
# The e-mail to use as the from address outgoing emails from Postal
from_address: postal@example.com
rails:
# The Rails environment to run the application in
environment: production
# The secret key used to sign and encrypt cookies and session data in the application
secret_key:
rspamd:
# Enable rspamd for message inspection
enabled: false
# The hostname of the rspamd server
host: 127.0.0.1
# The port of the rspamd server
port: 11334
# Enable SSL for the rspamd connection
ssl: false
# The password for the rspamd server
password:
# Any flags for the rspamd server
flags:
spamd:
# Enable SpamAssassin for message inspection
enabled: false
# The hostname for the SpamAssassin server
host: 127.0.0.1
# The port of the SpamAssassin server
port: 783
clamav:
# Enable ClamAV for message inspection
enabled: false
# The host of the ClamAV server
host: 127.0.0.1
# The port of the ClamAV server
port: 2000
smtp_client:
# The open timeout for outgoing SMTP connections
open_timeout: 30
# The read timeout for outgoing SMTP connections
read_timeout: 30
migration_waiter:
# Wait for all migrations to run before starting a process
enabled: false
# The number of attempts to try waiting for migrations to complete before start
attempts: 120
# The number of seconds to wait between each migration check
sleep_time: 2
oidc:
# Enable OIDC authentication
enabled: false
# When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available.
local_authentication_enabled: true
# The name of the OIDC provider as shown in the UI
name: OIDC Provider
# The OIDC issuer URL
issuer:
# The client ID for OIDC
identifier:
# The client secret for OIDC
secret:
# Scopes to request from the OIDC server.
scopes:
- openid
- email
# The field to use to determine the user's UID
uid_field: sub
# The field to use to determine the user's email address
email_address_field: email
# The field to use to determine the user's name
name_field: name
# Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer
discovery: true
# The authorize endpoint on the authorization server (only used when discovery is false)
authorization_endpoint:
# The token endpoint on the authorization server (only used when discovery is false)
token_endpoint:
# The user info endpoint on the authorization server (only used when discovery is false)
userinfo_endpoint:
# The JWKS endpoint on the authorization server (only used when discovery is false)
jwks_uri:
================================================
FILE: docker/ci-config/signing.key
================================================
-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQDWt+8XMJEoeOd5DW0ETcj7oklUiyd39h71ZsBxlZSOSydsb+ft
26C/dfJmT/1W38oxhct1iuRN6ETNHNeLtwrysxhpmSiea/eU8Iv3s4WVgdTJyneT
H++yuXwOyEmb1opc7igSRfbFLkeVcYm45rnzbjf/26UaFwmtZlxPI0LZrQIDAQAB
AoGAA8cOpMjM9PpTkDSlQ1se+xZa1erw0dJ5rvWU0yq/h1VZJzY8zVl81YF8t0IX
Ae1EAGULNFEyPRCmDTnBrQqWXbB5bqZIROKT+1Ruo1Kg6OUsrSDK3cDWYM2lInB/
/CXKo4pv82Zh9iS9qj7URFOEzEX43KNXCrfrLSOVRp+xnB0CQQDzxOhujaSqGlXW
wLdrTPRWp63SNbkhWK8FXnkeoyH02EvlWSIGQL2deDaOQk9VKd7kCes9MyWC9d7L
FO/e7GdDAkEA4X3ieo3Wted69PG3c+jN//nA1WZdgHKucGkOTk1MakqG6kf7DeVt
Uj2AFR8wxtKDYi9G+YxdKb+lI/T0CI9UTwJBALwf3UTcWRTRiCdYyPStCfAKLbIJ
tdrPRxr8orqLKPx9JG1WEVUEB5GMIYY+FF1kF9ii8wFjBHMB7rOJb+j5RmMCQA27
U8pwzs1/Dj7SZYCagcj/1Z1pQXJsCXFxBF0CWg/y/+pOfdxnx1OFyUIAB0FkWnnl
NSZHRPkg4Zah+SZ4TAMCQQDid1Cp6nhlntpOANWIXxkJJuDVYepQN1AgCvwZ3OMl
jOfmHNFkXlzb4EYr+uoab2cXV3TNVtau7Z5/Se1ZTVSp
-----END RSA PRIVATE KEY-----
================================================
FILE: docker/wait-for.sh
================================================
#!/bin/sh
[ -n "$DEBUG" ] && set -x
check_http() {
wget -T 1 -S -q -O - "$1" 2>&1 | head -1 |
head -1 | grep -E 'HTTP.+\s2\d{2}' >/dev/null 2>&1
return $?
}
check_tcp() {
host="$(echo "$1" | cut -d: -f1)"
port="$(echo "$1" | cut -d: -f2)"
if [ -z "${host}" ] || [ -z "${port}" ]; then
echo "TCP target ${1} is not in \":\" format" >&2
exit 2
fi
nc -z -w1 "$host" "$port" >/dev/null 2>&1
return $?
}
wait_for() {
type="$1"
uri="$2"
timeout="${3:-30}"
seconds=0
while [ "$seconds" -lt "$timeout" ] && ! "check_${type}" "$uri"; do
if [ "$seconds" -lt "1" ]; then
printf "Waiting for %s ." "$uri"
else
printf .
fi
seconds=$((seconds + 1))
sleep 1
done
if [ "$seconds" -lt "$timeout" ]; then
if [ "$seconds" -gt "0" ]; then
echo " up!"
fi
else
echo " FAIL"
echo "ERROR: unable to connect to: $uri" >&2
exit 1
fi
}
if [ -n "$WAIT_FOR_TARGETS" ]; then
uris="$(echo "$WAIT_FOR_TARGETS" | sed -e 's/\s+/\n/g' | uniq)"
for uri in $uris; do
if echo "$uri" | grep -E '^https?://.*' >/dev/null 2>&1; then
wait_for "http" "$uri" "$WAIT_FOR_TIMEOUT"
else
wait_for "tcp" "$uri" "$WAIT_FOR_TIMEOUT"
fi
done
fi
exec "$@"
================================================
FILE: docker-compose.yml
================================================
services:
postal:
image: ${POSTAL_IMAGE}
depends_on:
- mariadb
entrypoint: ["/docker-entrypoint.sh"]
volumes:
- "./docker/ci-config:/config"
environment:
POSTAL_SIGNING_KEY_PATH: /config/signing.key
MAIN_DB_HOST: mariadb
MAIN_DB_USERNAME: root
MESSAGE_DB_HOST: mariadb
MESSAGE_DB_USERNAME: root
LOGGING_ENABLED: "false"
RAILS_ENVIRONMENT: test
RAILS_LOG_ENABLED: "false"
WAIT_FOR_TIMEOUT: 90
WAIT_FOR_TARGETS: |-
mariadb:3306
mariadb:
image: mariadb
restart: always
environment:
MARIADB_DATABASE: postal
MARIADB_ALLOW_EMPTY_PASSWORD: 'yes'
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes'
================================================
FILE: lib/assets/.keep
================================================
================================================
FILE: lib/migration_waiter.rb
================================================
# frozen_string_literal: true
# This initializer will wait for all pending migrations to be applied before
# continuing to start the application. This is useful when running the application
# in a cluster where migrations are run in a separate job which runs at the same
# time as the other processes.
class MigrationWaiter
ATTEMPTS = Postal::Config.migration_waiter.attempts
SLEEP_TIME = Postal::Config.migration_waiter.sleep_time
class << self
def wait
attempts_remaining = ATTEMPTS
loop do
pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations.size
if pending_migrations.zero?
Postal.logger.info "no pending migrations, continuing"
return
end
attempts_remaining -= 1
if attempts_remaining.zero?
Postal.logger.info "#{pending_migrations} migration(s) are still pending after #{ATTEMPTS} attempts, exiting"
Process.exit(1)
else
Postal.logger.info "waiting for #{pending_migrations} migration(s) to be applied (#{attempts_remaining} remaining)"
sleep SLEEP_TIME
end
end
end
def wait_if_appropriate
# Don't wait if not configured
return unless Postal::Config.migration_waiter.enabled?
# Don't wait in the console, rake tasks or rails commands
return if console? || rake_task? || rails_command?
wait
end
def console?
Rails.const_defined?("Console")
end
def rake_task?
Rake.application.top_level_tasks.any?
end
def rails_command?
caller.any? { |c| c =~ /rails\/commands/ }
end
end
end
================================================
FILE: lib/postal/config.rb
================================================
# frozen_string_literal: true
require "erb"
require "yaml"
require "pathname"
require "cgi"
require "openssl"
require "fileutils"
require "konfig"
require "konfig/sources/environment"
require "konfig/sources/yaml"
require "dotenv"
require "klogger"
require_relative "error"
require_relative "config_schema"
require_relative "legacy_config_source"
require_relative "signer"
module Postal
class << self
attr_writer :current_process_type
# Return the path to the config file
#
# @return [String]
def config_file_path
ENV.fetch("POSTAL_CONFIG_FILE_PATH", "config/postal/postal.yml")
end
def initialize_config
sources = []
# Load environment variables to begin with. Any config provided
# by an environment variable will override any provided in the
# config file.
Dotenv.load(".env")
sources << Konfig::Sources::Environment.new(ENV)
silence_config_messages = ENV.fetch("SILENCE_POSTAL_CONFIG_MESSAGES", "false") == "true"
# If a config file exists, we need to load that. Config files can
# either be legacy (v1) or new (v2). Any file without a 'version'
# key is a legacy file whereas new-style config files will include
# the 'version: 2' key/value.
if File.file?(config_file_path)
unless silence_config_messages
warn "Loading config from #{config_file_path}"
end
config_file = File.read(config_file_path)
yaml = YAML.safe_load(config_file)
config_version = yaml["version"] || 1
case config_version
when 1
unless silence_config_messages
warn "WARNING: Using legacy config file format. Upgrade your postal.yml to use"
warn "version 2 of the Postal configuration or configure using environment"
warn "variables. See https://docs.postalserver.io/config-v2 for details."
end
sources << LegacyConfigSource.new(yaml)
when 2
sources << Konfig::Sources::YAML.new(config_file)
else
raise "Invalid version specified in Postal config file. Must be 1 or 2."
end
elsif !silence_config_messages
warn "No configuration file found at #{config_file_path}"
warn "Only using environment variables for configuration"
end
# Build configuration with the provided sources.
Konfig::Config.build(ConfigSchema, sources: sources)
end
def host_with_protocol
@host_with_protocol ||= "#{Config.postal.web_protocol}://#{Config.postal.web_hostname}"
end
def logger
@logger ||= begin
k = Klogger.new(nil, destination: Config.logging.enabled? ? $stdout : "/dev/null", highlight: Config.logging.highlighting_enabled?)
k.add_destination(graylog_logging_destination) if Config.gelf.host.present?
k
end
end
def process_name
@process_name ||= begin
"host:#{Socket.gethostname} pid:#{Process.pid}"
rescue StandardError
"pid:#{Process.pid}"
end
end
def locker_name
string = process_name.dup
string += " job:#{Thread.current[:job_id]}" if Thread.current[:job_id]
string += " thread:#{Thread.current.native_thread_id}"
string
end
def locker_name_with_suffix(suffix)
"#{locker_name} #{suffix}"
end
def signer
@signer ||= begin
key = OpenSSL::PKey::RSA.new(File.read(Config.postal.signing_key_path))
Signer.new(key)
end
end
def rp_dkim_dns_record
public_key = signer.private_key.public_key.to_s.gsub(/-+[A-Z ]+-+\n/, "").gsub(/\n/, "")
"v=DKIM1; t=s; h=sha256; p=#{public_key};"
end
def ip_pools?
Config.postal.use_ip_pools?
end
def graylog_logging_destination
@graylog_logging_destination ||= begin
notifier = GELF::Notifier.new(Config.gelf.host, Config.gelf.port, "WAN")
proc do |_logger, payload, group_ids|
short_message = payload.delete(:message) || "[message missing]"
notifier.notify!(short_message: short_message, **{
facility: Config.gelf.facility,
_environment: Config.rails.environment,
_version: Postal.version.to_s,
_group_ids: group_ids.join(" ")
}.merge(payload.transform_keys { |k| "_#{k}".to_sym }.transform_values(&:to_s)))
end
end
end
# Change the connection pool size to the given size.
#
# @param new_size [Integer]
# @return [void]
def change_database_connection_pool_size(new_size)
ActiveRecord::Base.connection_pool.disconnect!
config = ActiveRecord::Base.configurations
.configs_for(env_name: Config.rails.environment)
.first
.configuration_hash
ActiveRecord::Base.establish_connection(config.merge(pool: new_size))
end
# Return the branch name which created this release
#
# @return [String, nil]
def branch
return @branch if instance_variable_defined?("@branch")
@branch ||= read_version_file("BRANCH")
end
# Return the version
#
# @return [String, nil]
def version
return @version if instance_variable_defined?("@version")
@version ||= read_version_file("VERSION") || "0.0.0"
end
private
def read_version_file(file)
path = File.expand_path("../../../" + file, __FILE__)
return unless File.exist?(path)
value = File.read(path).strip
value.empty? ? nil : value
end
end
Config = initialize_config
end
================================================
FILE: lib/postal/config_schema.rb
================================================
# frozen_string_literal: true
require "uri"
module Postal
# REMEMBER: If you change the schema, remember to regenerate the configuration docs
# using the rake command below:
#
# rake postal:generate_config_docs
ConfigSchema = Konfig::Schema.draw do
group :postal do
string :web_hostname do
description "The hostname that the Postal web interface runs on"
default "postal.example.com"
end
string :web_protocol do
description "The HTTP protocol to use for the Postal web interface"
default "https"
end
string :smtp_hostname do
description "The hostname that the Postal SMTP server runs on"
default "postal.example.com"
end
boolean :use_ip_pools do
description "Should IP pools be enabled for this installation?"
default false
end
integer :default_maximum_delivery_attempts do
description "The maximum number of delivery attempts"
default 18
end
integer :default_maximum_hold_expiry_days do
description "The number of days to hold a message before they will be expired"
default 7
end
integer :default_suppression_list_automatic_removal_days do
description "The number of days an address will remain in a suppression list before being removed"
default 30
end
integer :default_spam_threshold do
description "The default threshold at which a message should be treated as spam"
default 5
end
integer :default_spam_failure_threshold do
description "The default threshold at which a message should be treated as spam failure"
default 20
end
boolean :use_local_ns_for_domain_verification do
description "Domain verification and checking usually checks with a domain's nameserver. Enable this to check with the server's local nameservers."
default false
end
boolean :use_resent_sender_header do
description "Append a Resend-Sender header to all outgoing e-mails"
default true
end
string :signing_key_path do
description "Path to the private key used for signing"
default "$config-file-root/signing.key"
transform { |v| Postal.substitute_config_file_root(v) }
end
string :smtp_relays do
array
description "An array of SMTP relays in the format of smtp://host:port"
transform do |value|
uri = URI.parse(value)
query = uri.query ? CGI.parse(uri.query) : {}
{
host: uri.host,
port: uri.port || 25,
ssl_mode: query["ssl_mode"]&.first || "Auto"
}
end
end
string :trusted_proxies do
array
description "An array of IP addresses to trust for proxying requests to Postal (in addition to localhost addresses)"
transform { |ip| IPAddr.new(ip) }
end
integer :queued_message_lock_stale_days do
description "The number of days after which to consider a lock as stale. Messages with stale locks will be removed and not retried."
default 1
end
boolean :batch_queued_messages do
description "When enabled queued messages will be de-queued in batches based on their destination"
default true
end
end
group :web_server do
integer :default_port do
description "The default port the web server should listen on unless overriden by the PORT environment variable"
default 5000
end
string :default_bind_address do
description "The default bind address the web server should listen on unless overriden by the BIND_ADDRESS environment variable"
default "127.0.0.1"
end
integer :max_threads do
description "The maximum number of threads which can be used by the web server"
default 5
end
end
group :worker do
integer :default_health_server_port do
description "The default port for the worker health server to listen on"
default 9090
end
string :default_health_server_bind_address do
description "The default bind address for the worker health server to listen on"
default "127.0.0.1"
end
integer :threads do
description "The number of threads to execute within each worker"
default 2
end
end
group :main_db do
string :host do
description "Hostname for the main MariaDB server"
default "localhost"
end
integer :port do
description "The MariaDB port to connect to"
default 3306
end
string :username do
description "The MariaDB username"
default "postal"
end
string :password do
description "The MariaDB password"
end
string :database do
description "The MariaDB database name"
default "postal"
end
integer :pool_size do
description "The maximum size of the MariaDB connection pool"
default 5
end
string :encoding do
description "The encoding to use when connecting to the MariaDB database"
default "utf8mb4"
end
end
group :message_db do
string :host do
description "Hostname for the MariaDB server which stores the mail server databases"
default "localhost"
end
integer :port do
description "The MariaDB port to connect to"
default 3306
end
string :username do
description "The MariaDB username"
default "postal"
end
string :password do
description "The MariaDB password"
end
string :encoding do
description "The encoding to use when connecting to the MariaDB database"
default "utf8mb4"
end
string :database_name_prefix do
description "The MariaDB prefix to add to database names"
default "postal"
end
end
group :logging do
boolean :rails_log_enabled do
description "Enable the default Rails logger"
default false
end
string :sentry_dsn do
description "A DSN which should be used to report exceptions to Sentry"
end
boolean :enabled do
description "Enable the Postal logger to log to STDOUT"
default true
end
boolean :highlighting_enabled do
description "Enable highlighting of log lines"
default false
end
end
group :gelf do
string :host do
description "GELF-capable host to send logs to"
end
integer :port do
description "GELF port to send logs to"
default 12_201
end
string :facility do
description "The facility name to add to all log entries sent to GELF"
default "postal"
end
end
group :smtp_server do
integer :default_port do
description "The default port the SMTP server should listen on unless overriden by the PORT environment variable"
default 25
end
string :default_bind_address do
description "The default bind address the SMTP server should listen on unless overriden by the BIND_ADDRESS environment variable"
default "::"
end
integer :default_health_server_port do
description "The default port for the SMTP server health server to listen on"
default 9091
end
string :default_health_server_bind_address do
description "The default bind address for the SMTP server health server to listen on"
default "127.0.0.1"
end
boolean :tls_enabled do
description "Enable TLS for the SMTP server (requires certificate)"
default false
end
string :tls_certificate_path do
description "The path to the SMTP server's TLS certificate"
default "$config-file-root/smtp.cert"
transform { |v| Postal.substitute_config_file_root(v) }
end
string :tls_private_key_path do
description "The path to the SMTP server's TLS private key"
default "$config-file-root/smtp.key"
transform { |v| Postal.substitute_config_file_root(v) }
end
string :tls_ciphers do
description "Override ciphers to use for SSL"
end
string :ssl_version do
description "The SSL versions which are supported"
default "SSLv23"
end
boolean :proxy_protocol do
description "Enable proxy protocol for use behind some load balancers (supports proxy protocol v1 only)"
default false
end
boolean :log_connections do
description "Enable connection logging"
default false
end
integer :max_message_size do
description "The maximum message size to accept from the SMTP server (in MB)"
default 14
end
string :log_ip_address_exclusion_matcher do
description "A regular expression to use to exclude connections from logging"
end
end
group :dns do
string :mx_records do
description "The names of the default MX records"
array
default ["mx1.postal.example.com", "mx2.postal.example.com"]
end
string :spf_include do
description "The location of the SPF record"
default "spf.postal.example.com"
end
string :return_path_domain do
description "The return path hostname"
default "rp.postal.example.com"
end
string :route_domain do
description "The domain to use for hosting route-specific addresses"
default "routes.postal.example.com"
end
string :track_domain do
description "The CNAME which tracking domains should be pointed to"
default "track.postal.example.com"
end
string :helo_hostname do
description "The hostname to use in HELO/EHLO when connecting to external SMTP servers"
end
string :dkim_identifier do
description "The identifier to use for DKIM keys in DNS records"
default "postal"
end
string :domain_verify_prefix do
description "The prefix to add before TXT record verification string"
default "postal-verification"
end
string :custom_return_path_prefix do
description "The domain to use on external domains which points to the Postal return path domain"
default "psrp"
end
integer :timeout do
description "The timeout to wait for DNS resolution"
default 5
end
string :resolv_conf_path do
description "The path to the resolv.conf file containing addresses for local nameservers"
default "/etc/resolv.conf"
end
end
group :smtp do
string :host do
description "The hostname to send application-level e-mails to"
default "127.0.0.1"
end
integer :port do
description "The port number to send application-level e-mails to"
default 25
end
string :username do
description "The username to use when authentication to the SMTP server"
end
string :password do
description "The password to use when authentication to the SMTP server"
end
string :authentication_type do
description "The type of authentication to use"
default "login"
end
boolean :enable_starttls do
description "Use STARTTLS when connecting to the SMTP server and fail if unsupported"
default false
end
boolean :enable_starttls_auto do
description "Detects if STARTTLS is enabled in the SMTP server and starts to use it"
default true
end
string :openssl_verify_mode do
description "When using TLS, you can set how OpenSSL checks the certificate. Use 'none' for no certificate checking"
default "peer"
end
string :from_name do
description "The name to use as the from name outgoing emails from Postal"
default "Postal"
end
string :from_address do
description "The e-mail to use as the from address outgoing emails from Postal"
default "postal@example.com"
end
end
group :rails do
string :environment do
description "The Rails environment to run the application in"
default "production"
end
string :secret_key do
description "The secret key used to sign and encrypt cookies and session data in the application"
end
end
group :rspamd do
boolean :enabled do
description "Enable rspamd for message inspection"
default false
end
string :host do
description "The hostname of the rspamd server"
default "127.0.0.1"
end
integer :port do
description "The port of the rspamd server"
default 11_334
end
boolean :ssl do
description "Enable SSL for the rspamd connection"
default false
end
string :password do
description "The password for the rspamd server"
end
string :flags do
description "Any flags for the rspamd server"
end
end
group :spamd do
boolean :enabled do
description "Enable SpamAssassin for message inspection"
default false
end
string :host do
description "The hostname for the SpamAssassin server"
default "127.0.0.1"
end
integer :port do
description "The port of the SpamAssassin server"
default 783
end
end
group :clamav do
boolean :enabled do
description "Enable ClamAV for message inspection"
default false
end
string :host do
description "The host of the ClamAV server"
default "127.0.0.1"
end
integer :port do
description "The port of the ClamAV server"
default 2000
end
end
group :smtp_client do
integer :open_timeout do
description "The open timeout for outgoing SMTP connections"
default 30
end
integer :read_timeout do
description "The read timeout for outgoing SMTP connections"
default 30
end
end
group :migration_waiter do
boolean :enabled do
description "Wait for all migrations to run before starting a process"
default false
end
integer :attempts do
description "The number of attempts to try waiting for migrations to complete before start"
default 120
end
integer :sleep_time do
description "The number of seconds to wait between each migration check"
default 2
end
end
group :oidc do
boolean :enabled do
description "Enable OIDC authentication"
default false
end
boolean :local_authentication_enabled do
description "When enabled, users with passwords will still be able to login locally. If disable, only OpenID Connect will be available."
default true
end
string :name do
description "The name of the OIDC provider as shown in the UI"
default "OIDC Provider"
end
string :issuer do
description "The OIDC issuer URL"
end
string :identifier do
description "The client ID for OIDC"
end
string :secret do
description "The client secret for OIDC"
end
string :scopes do
description "Scopes to request from the OIDC server."
array
default ["openid", "email"]
end
string :uid_field do
description "The field to use to determine the user's UID"
default "sub"
end
string :email_address_field do
description "The field to use to determine the user's email address"
default "email"
end
string :name_field do
description "The field to use to determine the user's name"
default "name"
end
boolean :discovery do
description "Enable discovery to determine endpoints from .well-known/openid-configuration from the Issuer"
default true
end
string :authorization_endpoint do
description "The authorize endpoint on the authorization server (only used when discovery is false)"
end
string :token_endpoint do
description "The token endpoint on the authorization server (only used when discovery is false)"
end
string :userinfo_endpoint do
description "The user info endpoint on the authorization server (only used when discovery is false)"
end
string :jwks_uri do
description "The JWKS endpoint on the authorization server (only used when discovery is false)"
end
end
end
class << self
def substitute_config_file_root(string)
return if string.nil?
string.gsub(/\$config-file-root/i, File.dirname(Postal.config_file_path))
end
end
end
================================================
FILE: lib/postal/error.rb
================================================
# frozen_string_literal: true
module Postal
class Error < StandardError
end
module Errors
class AuthenticationError < Error
attr_reader :error
def initialize(error)
super()
@error = error
end
def to_s
"Authentication Failed: #{@error}"
end
end
end
end
================================================
FILE: lib/postal/helm_config_exporter.rb
================================================
# frozen_string_literal: true
require "konfig/exporters/abstract"
module Postal
class HelmConfigExporter < Konfig::Exporters::Abstract
def export
contents = []
path = []
@schema.groups.each do |group_name, group|
path << group_name
group.attributes.each do |name, _|
env_var = Konfig::Sources::Environment.path_to_env_var(path + [name])
contents << <<~VAR.strip
{{ include "app.envVar" (dict "name" "#{env_var}" "spec" .Values.postal.#{path.join('.')}.#{name} "root" . ) }}
VAR
end
path.pop
end
contents.join("\n")
end
end
end
================================================
FILE: lib/postal/helpers.rb
================================================
# frozen_string_literal: true
module Postal
module Helpers
def self.strip_name_from_address(address)
return nil if address.nil?
address.gsub(/.*, "").gsub(/>.*/, "").gsub(/\(.+?\)/, "").strip
end
end
end
================================================
FILE: lib/postal/http.rb
================================================
# frozen_string_literal: true
require "net/https"
require "uri"
module Postal
module HTTP
def self.get(url, options = {})
request(Net::HTTP::Get, url, options)
end
def self.post(url, options = {})
request(Net::HTTP::Post, url, options)
end
def self.request(method, url, options = {})
options[:headers] ||= {}
uri = URI.parse(url)
request = method.new((uri.path.empty? ? "/" : uri.path) + (uri.query ? "?" + uri.query : ""))
options[:headers].each { |k, v| request.add_field k, v }
if options[:username] || uri.user
request.basic_auth(options[:username] || uri.user, options[:password] || uri.password)
end
if options[:params].is_a?(Hash)
# If params has been provided, sent it them as form encoded values
request.set_form_data(options[:params])
elsif options[:json].is_a?(String)
# If we have a JSON string, set the content type and body to be the JSON
# data
request.add_field "Content-Type", "application/json"
request.body = options[:json]
elsif options[:text_body]
# Add a plain text body if we have one
request.body = options[:text_body]
end
if options[:sign]
request.add_field "X-Postal-Signature-KID", Postal.signer.jwk.kid
request.add_field "X-Postal-Signature", Postal.signer.sha1_sign64(request.body.to_s)
request.add_field "X-Postal-Signature-256", Postal.signer.sign64(request.body.to_s)
end
request["User-Agent"] = options[:user_agent] || "Postal/#{Postal.version}"
connection = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == "https"
connection.use_ssl = true
connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
ssl = true
else
ssl = false
end
begin
timeout = options[:timeout] || 60
Timeout.timeout(timeout) do
result = connection.request(request)
{
code: result.code.to_i,
body: result.body,
headers: result.to_hash,
secure: ssl
}
end
rescue OpenSSL::SSL::SSLError
{
code: -3,
body: "Invalid SSL certificate",
headers: {},
secure: ssl
}
rescue SocketError, Errno::ECONNRESET, EOFError, Errno::EINVAL, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ECONNREFUSED => e
{
code: -2,
body: e.message,
headers: {},
secure: ssl
}
rescue Timeout::Error
{
code: -1,
body: "Timed out after #{timeout}s",
headers: {},
secure: ssl
}
end
end
end
end
================================================
FILE: lib/postal/legacy_config_source.rb
================================================
# frozen_string_literal: true
require "konfig/sources/abstract"
require "konfig/error"
module Postal
class LegacyConfigSource < Konfig::Sources::Abstract
# This maps all the new configuration values to where they
# exist in the old YAML file. The source will load any YAML
# file that has been provided to this source in order. A
# warning will be generated to the console for configuration
# loaded from this format.
MAPPING = {
"postal.web_hostname" => -> (c) { c.dig("web", "host") },
"postal.web_protocol" => -> (c) { c.dig("web", "protocol") },
"postal.smtp_hostname" => -> (c) { c.dig("dns", "smtp_server_hostname") },
"postal.use_ip_pools" => -> (c) { c.dig("general", "use_ip_pools") },
"logging.sentry_dsn" => -> (c) { c.dig("general", "exception_url") },
"postal.default_maximum_delivery_attempts" => -> (c) { c.dig("general", "maximum_delivery_attempts") },
"postal.default_maximum_hold_expiry_days" => -> (c) { c.dig("general", "maximum_hold_expiry_days") },
"postal.default_suppression_list_automatic_removal_days" => -> (c) { c.dig("general", "suppression_list_removal_delay") },
"postal.use_local_ns_for_domain_verification" => -> (c) { c.dig("general", "use_local_ns_for_domains") },
"postal.default_spam_threshold" => -> (c) { c.dig("general", "default_spam_threshold") },
"postal.default_spam_failure_threshold" => -> (c) { c.dig("general", "default_spam_failure_threshold") },
"postal.use_resent_sender_header" => -> (c) { c.dig("general", "use_resent_sender_header") },
# SMTP relays must be converted to the new URI style format and they'll
# then be transformed back to a hash by the schema transform.
"postal.smtp_relays" => -> (c) { c["smtp_relays"]&.map { |r| "smtp://#{r['hostname']}:#{r['port']}?ssl_mode=#{r['ssl_mode']}" } },
"web_server.default_bind_address" => -> (c) { c.dig("web_server", "bind_address") },
"web_server.default_port" => -> (c) { c.dig("web_server", "port") },
"web_server.max_threads" => -> (c) { c.dig("web_server", "max_threads") },
"main_db.host" => -> (c) { c.dig("main_db", "host") },
"main_db.port" => -> (c) { c.dig("main_db", "port") },
"main_db.username" => -> (c) { c.dig("main_db", "username") },
"main_db.password" => -> (c) { c.dig("main_db", "password") },
"main_db.database" => -> (c) { c.dig("main_db", "database") },
"main_db.pool_size" => -> (c) { c.dig("main_db", "pool_size") },
"main_db.encoding" => -> (c) { c.dig("main_db", "encoding") },
"message_db.host" => -> (c) { c.dig("message_db", "host") },
"message_db.port" => -> (c) { c.dig("message_db", "port") },
"message_db.username" => -> (c) { c.dig("message_db", "username") },
"message_db.password" => -> (c) { c.dig("message_db", "password") },
"message_db.database_name_prefix" => -> (c) { c.dig("message_db", "prefix") },
"logging.rails_log_enabled" => -> (c) { c.dig("logging", "rails_log") },
"gelf.host" => -> (c) { c.dig("logging", "graylog", "host") },
"gelf.port" => -> (c) { c.dig("logging", "graylog", "port") },
"gelf.facility" => -> (c) { c.dig("logging", "graylog", "facility") },
"smtp_server.default_port" => -> (c) { c.dig("smtp_server", "port") },
"smtp_server.default_bind_address" => -> (c) { c.dig("smtp_server", "bind_address") || "::" },
"smtp_server.tls_enabled" => -> (c) { c.dig("smtp_server", "tls_enabled") },
"smtp_server.tls_certificate_path" => -> (c) { c.dig("smtp_server", "tls_certificate_path") },
"smtp_server.tls_private_key_path" => -> (c) { c.dig("smtp_server", "tls_private_key_path") },
"smtp_server.tls_ciphers" => -> (c) { c.dig("smtp_server", "tls_ciphers") },
"smtp_server.ssl_version" => -> (c) { c.dig("smtp_server", "ssl_version") },
"smtp_server.proxy_protocol" => -> (c) { c.dig("smtp_server", "proxy_protocol") },
"smtp_server.log_connections" => -> (c) { c.dig("smtp_server", "log_connect") },
"smtp_server.max_message_size" => -> (c) { c.dig("smtp_server", "max_message_size") },
"dns.mx_records" => -> (c) { c.dig("dns", "mx_records") },
"dns.spf_include" => -> (c) { c.dig("dns", "spf_include") },
"dns.return_path_domain" => -> (c) { c.dig("dns", "return_path") },
"dns.route_domain" => -> (c) { c.dig("dns", "route_domain") },
"dns.track_domain" => -> (c) { c.dig("dns", "track_domain") },
"dns.helo_hostname" => -> (c) { c.dig("dns", "helo_hostname") },
"dns.dkim_identifier" => -> (c) { c.dig("dns", "dkim_identifier") },
"dns.domain_verify_prefix" => -> (c) { c.dig("dns", "domain_verify_prefix") },
"dns.custom_return_path_prefix" => -> (c) { c.dig("dns", "custom_return_path_prefix") },
"smtp.host" => -> (c) { c.dig("smtp", "host") },
"smtp.port" => -> (c) { c.dig("smtp", "port") },
"smtp.username" => -> (c) { c.dig("smtp", "username") },
"smtp.password" => -> (c) { c.dig("smtp", "password") },
"smtp.from_name" => -> (c) { c.dig("smtp", "from_name") },
"smtp.from_address" => -> (c) { c.dig("smtp", "from_address") },
"rails.environment" => -> (c) { c.dig("rails", "environment") },
"rails.secret_key" => -> (c) { c.dig("rails", "secret_key") },
"rspamd.enabled" => -> (c) { c.dig("rspamd", "enabled") },
"rspamd.host" => -> (c) { c.dig("rspamd", "host") },
"rspamd.port" => -> (c) { c.dig("rspamd", "port") },
"rspamd.ssl" => -> (c) { c.dig("rspamd", "ssl") },
"rspamd.password" => -> (c) { c.dig("rspamd", "password") },
"rspamd.flags" => -> (c) { c.dig("rspamd", "flags") },
"spamd.enabled" => -> (c) { c.dig("spamd", "enabled") },
"spamd.host" => -> (c) { c.dig("spamd", "host") },
"spamd.port" => -> (c) { c.dig("spamd", "port") },
"clamav.enabled" => -> (c) { c.dig("clamav", "enabled") },
"clamav.host" => -> (c) { c.dig("clamav", "host") },
"clamav.port" => -> (c) { c.dig("clamav", "port") },
"smtp_client.open_timeout" => -> (c) { c.dig("smtp_client", "open_timeout") },
"smtp_client.read_timeout" => -> (c) { c.dig("smtp_client", "read_timeout") }
}.freeze
def initialize(config)
super()
@config = config
end
def get(path, attribute: nil)
path_string = path.join(".")
raise Konfig::ValueNotPresentError unless MAPPING.key?(path_string)
legacy_value = MAPPING[path_string].call(@config)
raise Konfig::ValueNotPresentError if legacy_value.nil?
legacy_value
end
end
end
================================================
FILE: lib/postal/message_db/click.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Click
def initialize(attributes, link)
@url = link["url"]
@ip_address = attributes["ip_address"]
@user_agent = attributes["user_agent"]
@timestamp = Time.zone.at(attributes["timestamp"])
end
attr_reader :ip_address
attr_reader :user_agent
attr_reader :timestamp
attr_reader :url
end
end
end
================================================
FILE: lib/postal/message_db/connection_pool.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class ConnectionPool
attr_reader :connections
def initialize
@connections = []
@lock = Mutex.new
end
def use
retried = false
do_not_checkin = false
begin
connection = checkout
yield connection
rescue Mysql2::Error => e
if e.message =~ /(lost connection|gone away|not connected)/i
# If the connection has failed for a connectivity reason
# we won't add it back in to the pool so that it'll reconnect
# next time.
do_not_checkin = true
# If we haven't retried yet, we'll retry the block once more.
if retried == false
retried = true
retry
end
end
raise
ensure
checkin(connection) unless do_not_checkin
end
end
private
def checkout
@lock.synchronize do
return @connections.pop unless @connections.empty?
end
add_new_connection
checkout
end
def checkin(connection)
@lock.synchronize do
@connections << connection
end
end
def add_new_connection
@lock.synchronize do
@connections << establish_connection
end
end
def establish_connection
Mysql2::Client.new(
host: Postal::Config.message_db.host,
username: Postal::Config.message_db.username,
password: Postal::Config.message_db.password,
port: Postal::Config.message_db.port,
encoding: Postal::Config.message_db.encoding
)
end
end
end
end
================================================
FILE: lib/postal/message_db/database.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Database
class << self
def connection_pool
@connection_pool ||= ConnectionPool.new
end
end
def initialize(organization_id, server_id, database_name: nil)
@organization_id = organization_id
@server_id = server_id
@database_name = database_name
end
attr_reader :organization_id
attr_reader :server_id
#
# Return the server
#
def server
@server ||= Server.find_by_id(@server_id)
end
#
# Return the current schema version
#
def schema_version
@schema_version ||= begin
last_migration = select(:migrations, order: :version, direction: "DESC", limit: 1).first
last_migration ? last_migration["version"] : 0
rescue Mysql2::Error => e
e.message =~ /doesn't exist/ ? 0 : raise
end
end
#
# Return a single message. Accepts an ID or an array of conditions
#
def message(*args)
Message.find_one(self, *args)
end
#
# Return an array or count of messages.
#
def messages(*args)
Message.find(self, *args)
end
def messages_with_pagination(*args)
Message.find_with_pagination(self, *args)
end
#
# Create a new message with the given attributes. This won't be saved to the database
# until it has been 'save'd.
#
def new_message(attributes = {})
Message.new(self, attributes)
end
#
# Return the total size of all stored messages
#
def total_size
query("SELECT SUM(size) AS size FROM `#{database_name}`.`raw_message_sizes`").first["size"] || 0
end
#
# Return the live stats instance
#
def live_stats
@live_stats ||= LiveStats.new(self)
end
#
# Return the statistics instance
#
def statistics
@statistics ||= Statistics.new(self)
end
#
# Return the provisioner instance
#
def provisioner
@provisioner ||= Provisioner.new(self)
end
#
# Return the provisioner instance
#
def suppression_list
@suppression_list ||= SuppressionList.new(self)
end
#
# Return the provisioner instance
#
def webhooks
@webhooks ||= Webhooks.new(self)
end
#
# Return the name for a raw message table for a given date
#
def raw_table_name_for_date(date)
date.strftime("raw-%Y-%m-%d")
end
#
# Insert a new raw message into a table (creating it if needed)
#
def insert_raw_message(data, date = Time.now.utc.to_date)
table_name = raw_table_name_for_date(date)
begin
headers, body = data.split(/\r?\n\r?\n/, 2)
headers_id = insert(table_name, data: headers)
body_id = insert(table_name, data: body)
rescue Mysql2::Error => e
raise unless e.message =~ /doesn't exist/
provisioner.create_raw_table(table_name)
retry
end
[table_name, headers_id, body_id]
end
#
# Selects entries from the database. Accepts a number of options which can be used
# to manipulate the results.
#
# :where => A hash containing the query
# :order => The name of a field to order by
# :direction => The order that should be applied to ordering (ASC or DESC)
# :fields => An array of fields to select
# :limit => Limit the number of results
# :page => Which page number to return
# :per_page => The number of items per page (defaults to 30)
# :count => Return a count of the results instead of the actual data
#
def select(table, options = {})
sql_query = String.new("SELECT")
if options[:count]
sql_query << " COUNT(id) AS count"
elsif options[:fields]
sql_query << (" " + options[:fields].map { |f| "`#{f}`" }.join(", "))
else
sql_query << " *"
end
sql_query << " FROM `#{database_name}`.`#{table}`"
if options[:where].present?
sql_query << (" " + build_where_string(options[:where], " AND "))
end
if options[:order]
direction = (options[:direction] || "ASC").upcase
raise Postal::Error, "Invalid direction #{options[:direction]}" unless %w[ASC DESC].include?(direction)
sql_query << " ORDER BY `#{options[:order]}` #{direction}"
end
if options[:limit]
sql_query << " LIMIT #{options[:limit]}"
end
if options[:offset]
sql_query << " OFFSET #{options[:offset]}"
end
result = query(sql_query)
if options[:count]
result.first["count"]
else
result.to_a
end
end
#
# A paginated version of select
#
def select_with_pagination(table, page, options = {})
page = page.to_i
page = 1 if page <= 0
per_page = options.delete(:per_page) || 30
offset = (page - 1) * per_page
result = {}
result[:total] = select(table, options.merge(count: true))
result[:records] = select(table, options.merge(limit: per_page, offset: offset))
result[:per_page] = per_page
result[:total_pages], remainder = result[:total].divmod(per_page)
result[:total_pages] += 1 if remainder.positive?
result[:page] = page
result
end
#
# Updates a record in the database. Accepts a table name, the attributes to update
# plus some options which are shown below:
#
# :where => The condition to apply to the query
#
# Will return the total number of affected rows.
#
def update(table, attributes, options = {})
sql_query = "UPDATE `#{database_name}`.`#{table}` SET"
sql_query << " #{hash_to_sql(attributes)}"
if options[:where]
sql_query << (" " + build_where_string(options[:where]))
end
with_mysql do |mysql|
query_on_connection(mysql, sql_query)
mysql.affected_rows
end
end
#
# Insert a record into a given table. A hash of attributes is also provided.
# Will return the ID of the new item.
#
def insert(table, attributes)
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
sql_query << (" (" + attributes.keys.map { |k| "`#{k}`" }.join(", ") + ")")
sql_query << (" VALUES (" + attributes.values.map { |v| escape(v) }.join(", ") + ")")
with_mysql do |mysql|
query_on_connection(mysql, sql_query)
mysql.last_id
end
end
#
# Insert multiple rows at the same time in the same query
#
def insert_multi(table, keys, values)
if values.empty?
nil
else
sql_query = "INSERT INTO `#{database_name}`.`#{table}`"
sql_query << (" (" + keys.map { |k| "`#{k}`" }.join(", ") + ")")
sql_query << " VALUES "
sql_query << values.map { |v| "(" + v.map { |r| escape(r) }.join(", ") + ")" }.join(", ")
query(sql_query)
end
end
#
# Deletes a in the database. Accepts a table name, and some options which
# are shown below:
#
# :where => The condition to apply to the query
#
# Will return the total number of affected rows.
#
def delete(table, options = {})
sql_query = "DELETE FROM `#{database_name}`.`#{table}`"
sql_query << (" " + build_where_string(options[:where], " AND "))
with_mysql do |mysql|
query_on_connection(mysql, sql_query)
mysql.affected_rows
end
end
#
# Return the correct database name
#
def database_name
@database_name ||= "#{Postal::Config.message_db.database_name_prefix}-server-#{@server_id}"
end
#
# Run a query, log it and return the result
#
class ResultForExplainPrinter
attr_reader :columns
attr_reader :rows
def initialize(result)
if result.first
@columns = result.first.keys
@rows = result.map { |row| row.map(&:last) }
else
@columns = []
@rows = []
end
end
end
def stringify_keys(hash)
hash.transform_keys(&:to_s)
end
def escape(value)
with_mysql do |mysql|
if value == true
"1"
elsif value == false
"0"
elsif value.nil? || value.to_s.empty?
"NULL"
else
"'" + mysql.escape(value.to_s) + "'"
end
end
end
def query(query)
with_mysql do |mysql|
query_on_connection(mysql, query)
end
end
private
def query_on_connection(connection, query)
start_time = Time.now.to_f
result = connection.query(query, cast_booleans: true)
time = Time.now.to_f - start_time
logger.debug " \e[4;34mMessageDB Query (#{time.round(2)}s) \e[0m \e[33m#{query}\e[0m"
if time > 0.05 && query =~ /\A(SELECT|UPDATE|DELETE) /
id = SecureRandom.alphanumeric(8)
explain_result = ResultForExplainPrinter.new(connection.query("EXPLAIN #{query}"))
logger.info " [#{id}] EXPLAIN #{query}"
ActiveRecord::ConnectionAdapters::MySQL::ExplainPrettyPrinter.new.pp(explain_result, time).split("\n").each do |line|
logger.info " [#{id}] " + line
end
end
result
end
def logger
defined?(Rails) ? Rails.logger : Logger.new($stdout)
end
def with_mysql(&block)
self.class.connection_pool.use(&block)
end
def build_where_string(attributes, joiner = ", ")
"WHERE #{hash_to_sql(attributes, joiner)}"
end
def hash_to_sql(hash, joiner = ", ")
hash.map do |key, value|
if value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }
"`#{key}` IN (#{value.join(', ')})"
elsif value.is_a?(Array)
escaped_values = value.map { |v| escape(v) }.join(", ")
"`#{key}` IN (#{escaped_values})"
elsif value.is_a?(Hash)
sql = []
value.each do |operator, inner_value|
case operator
when :less_than
sql << "`#{key}` < #{escape(inner_value)}"
when :greater_than
sql << "`#{key}` > #{escape(inner_value)}"
when :less_than_or_equal_to
sql << "`#{key}` <= #{escape(inner_value)}"
when :greater_than_or_equal_to
sql << "`#{key}` >= #{escape(inner_value)}"
end
end
sql.empty? ? "1=1" : sql.join(joiner)
else
"`#{key}` = #{escape(value)}"
end
end.join(joiner)
end
end
end
end
================================================
FILE: lib/postal/message_db/delivery.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Delivery
def self.create(message, attributes = {})
attributes = message.database.stringify_keys(attributes)
attributes = attributes.merge("message_id" => message.id, "timestamp" => Time.now.to_f)
# Ensure that output and details don't overflow their columns. We don't need
# these values to store more than 250 characters.
attributes["output"] = attributes["output"][0, 250] if attributes["output"]
attributes["details"] = attributes["details"][0, 250] if attributes["details"]
id = message.database.insert("deliveries", attributes)
delivery = Delivery.new(message, attributes.merge("id" => id))
delivery.update_statistics
delivery.send_webhooks
delivery
end
def initialize(message, attributes)
@message = message
@attributes = attributes.stringify_keys
end
def method_missing(name, value = nil, &block)
return unless @attributes.key?(name.to_s)
@attributes[name.to_s]
end
def respond_to_missing?(name, include_private = false)
@attributes.key?(name.to_s)
end
def timestamp
@timestamp ||= @attributes["timestamp"] ? Time.zone.at(@attributes["timestamp"]) : nil
end
def update_statistics
if status == "Held"
@message.database.statistics.increment_all(timestamp, "held")
end
return unless status == "Bounced" || status == "HardFail"
@message.database.statistics.increment_all(timestamp, "bounces")
end
def send_webhooks
return unless webhook_event
WebhookRequest.trigger(@message.database.server_id, webhook_event, webhook_hash)
end
def webhook_hash
{
message: @message.webhook_hash,
status: status,
details: details,
output: output.to_s.dup.force_encoding("UTF-8").scrub.truncate(512),
sent_with_ssl: sent_with_ssl,
timestamp: @attributes["timestamp"],
time: time
}
end
# rubocop:disable Style/HashLikeCase
def webhook_event
@webhook_event ||= case status
when "Sent" then "MessageSent"
when "SoftFail" then "MessageDelayed"
when "HardFail" then "MessageDeliveryFailed"
when "Held" then "MessageHeld"
end
end
# rubocop:enable Style/HashLikeCase
end
end
end
================================================
FILE: lib/postal/message_db/live_stats.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class LiveStats
def initialize(database)
@database = database
end
#
# Increment the live stats by one for the current minute
#
def increment(type)
time = Time.now.utc
type = @database.escape(type.to_s)
sql_query = "INSERT INTO `#{@database.database_name}`.`live_stats` (type, minute, timestamp, count)"
sql_query << " VALUES (#{type}, #{time.min}, #{time.to_f}, 1)"
sql_query << " ON DUPLICATE KEY UPDATE count = if(timestamp < #{time.to_f - 1800}, 1, count + 1), timestamp = #{time.to_f}"
@database.query(sql_query)
end
#
# Return the total number of messages for the last 60 minutes
#
def total(minutes, options = {})
if minutes > 60
raise Postal::Error, "Live stats can only return data for the last 60 minutes."
end
options[:types] ||= [:incoming, :outgoing]
raise Postal::Error, "You must provide at least one type to return" if options[:types].empty?
time = minutes.minutes.ago.beginning_of_minute.utc.to_f
types = options[:types].map { |t| @database.escape(t.to_s) }.join(", ")
result = @database.query("SELECT SUM(count) as count FROM `#{@database.database_name}`.`live_stats` WHERE `type` IN (#{types}) AND timestamp > #{time}").first
result["count"] || 0
end
end
end
end
================================================
FILE: lib/postal/message_db/load.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Load
def initialize(attributes)
@ip_address = attributes["ip_address"]
@user_agent = attributes["user_agent"]
@timestamp = Time.zone.at(attributes["timestamp"])
end
attr_reader :ip_address
attr_reader :user_agent
attr_reader :timestamp
end
end
end
================================================
FILE: lib/postal/message_db/message.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Message
class NotFound < Postal::Error
end
def self.find_one(database, query)
query = { id: query.to_i } if query.is_a?(Integer)
raise NotFound, "No message found matching provided query #{query}" unless message = database.select("messages", where: query, limit: 1).first
Message.new(database, message)
end
def self.find(database, options = {})
if messages = database.select("messages", options)
if messages.is_a?(Array)
messages.map { |m| Message.new(database, m) }
else
messages
end
else
[]
end
end
def self.find_with_pagination(database, page, options = {})
messages = database.select_with_pagination("messages", page, options)
messages[:records] = messages[:records].map { |m| Message.new(database, m) }
messages
end
attr_reader :database
def initialize(database, attributes)
@database = database
@attributes = attributes
end
def reload
self.class.find_one(@database, @attributes["id"])
end
#
# Return the server for this message
#
def server
@database.server
end
#
# Return the credential for this message
#
def credential
@credential ||= credential_id ? Credential.find_by_id(credential_id) : nil
end
#
# Return the route for this message
#
def route
@route ||= route_id ? Route.find_by_id(route_id) : nil
end
#
# Return the endpoint for this message
#
def endpoint
if endpoint_type && endpoint_id
@endpoint ||= endpoint_type.constantize.find_by_id(endpoint_id)
elsif route && route.mode == "Endpoint"
@endpoint ||= route.endpoint
end
end
#
# Return the credential for this message
#
def domain
@domain ||= domain_id ? Domain.find_by_id(domain_id) : nil
end
#
# Copy appropriate attributes from the raw message to the message itself
#
def copy_attributes_from_raw_message
return unless raw_message
self.subject = headers["subject"]&.last.to_s[0, 200]
self.message_id = headers["message-id"]&.last
return unless message_id
self.message_id = message_id.gsub(/.*, "").gsub(/>.*/, "").strip
end
#
# Return the timestamp for this message
#
def timestamp
@timestamp ||= @attributes["timestamp"] ? Time.zone.at(@attributes["timestamp"]) : nil
end
#
# Return the time that the last delivery was attempted
#
def last_delivery_attempt
@last_delivery_attempt ||= @attributes["last_delivery_attempt"] ? Time.zone.at(@attributes["last_delivery_attempt"]) : nil
end
#
# Return the hold expiry for this message
#
def hold_expiry
@hold_expiry ||= @attributes["hold_expiry"] ? Time.zone.at(@attributes["hold_expiry"]) : nil
end
#
# Has this message been read?
#
def read?
!!(loaded || clicked)
end
#
# Add a delivery attempt for this message
#
def create_delivery(status, options = {})
delivery = Delivery.create(self, options.merge(status: status))
hold_expiry = status == "Held" ? Postal::Config.postal.default_maximum_hold_expiry_days.days.from_now.to_f : nil
update(status: status, last_delivery_attempt: delivery.timestamp.to_f, held: status == "Held", hold_expiry: hold_expiry)
delivery
end
#
# Return all deliveries for this object
#
def deliveries
@deliveries ||= @database.select("deliveries", where: { message_id: id }, order: :timestamp).map do |hash|
Delivery.new(self, hash)
end
end
#
# Return all the clicks for this object
#
def clicks
@clicks ||= begin
clicks = @database.select("clicks", where: { message_id: id }, order: :timestamp)
if clicks.empty?
[]
else
links = @database.select("links", where: { id: clicks.map { |c| c["link_id"].to_i } }).group_by { |l| l["id"] }
clicks.map do |hash|
Click.new(hash, links[hash["link_id"]].first)
end
end
end
end
#
# Return all the loads for this object
#
def loads
@loads ||= begin
loads = @database.select("loads", where: { message_id: id }, order: :timestamp)
loads.map do |hash|
Load.new(hash)
end
end
end
#
# Return all activity entries
#
def activity_entries
@activity_entries ||= (deliveries + clicks + loads).sort_by(&:timestamp)
end
#
# Provide access to set and get acceptable attributes
#
def method_missing(name, value = nil, &block)
if @attributes.key?(name.to_s)
@attributes[name.to_s]
elsif name.to_s =~ /=\z/
@attributes[name.to_s.gsub("=", "").to_s] = value
end
end
def respond_to_missing?(name, include_private = false)
name = name.to_s.sub(/=\z/, "")
@attributes.key?(name.to_s)
end
#
# Has this message been persisted to the database yet?
#
def persisted?
!@attributes["id"].nil?
end
#
# Save this message
#
def save(queue_on_create: true)
save_raw_message
persisted? ? _update : _create(queue: queue_on_create)
self
end
#
# Update this message
#
def update(attributes_to_change)
@attributes = @attributes.merge(database.stringify_keys(attributes_to_change))
if persisted?
@database.update("messages", attributes_to_change, where: { id: id })
else
_create
end
end
#
# Delete the message from the database
#
def delete
return unless persisted?
@database.delete("messages", where: { id: id })
end
#
# Return the headers
#
def raw_headers
if raw_table
@raw_headers ||= @database.select(raw_table, where: { id: raw_headers_id }).first&.send(:[], "data") || ""
else
""
end
end
#
# Return the full raw message body for this message.
#
def raw_body
if raw_table
@raw ||= @database.select(raw_table, where: { id: raw_body_id }).first&.send(:[], "data") || ""
else
""
end
end
#
# Return the full raw message for this message
#
def raw_message
@raw_message ||= "#{raw_headers}\r\n\r\n#{raw_body}"
end
#
# Set the raw message ready for saving later
#
def raw_message=(raw)
@pending_raw_message = raw.force_encoding("BINARY")
end
#
# Save the raw message to the database as appropriate
#
def save_raw_message
return unless @pending_raw_message
self.size = @pending_raw_message.bytesize
date = Time.now.utc.to_date
table_name, headers_id, body_id = @database.insert_raw_message(@pending_raw_message, date)
self.raw_table = table_name
self.raw_headers_id = headers_id
self.raw_body_id = body_id
@raw = nil
@raw_headers = nil
@headers = nil
@mail = nil
@pending_raw_message = nil
copy_attributes_from_raw_message
@database.query("UPDATE `#{@database.database_name}`.`raw_message_sizes` SET size = size + #{size} WHERE table_name = '#{table_name}'")
end
#
# Is there a raw message?
#
def raw_message?
!!raw_table
end
#
# Return the plain body for this message
#
def plain_body
mail&.plain_body
end
#
# Return the HTML body for this message
#
def html_body
mail&.html_body
end
#
# Return the HTML body with any tracking links
#
def html_body_without_tracking_image
html_body.gsub(//, "")
end
#
# Return all attachments for this message
#
def attachments
mail&.attachments || []
end
#
# Return the headers for this message
#
def headers
@headers ||= begin
mail = Mail.new(raw_headers)
mail.header.fields.each_with_object({}) do |field, hash|
hash[field.name.downcase] ||= []
begin
hash[field.name.downcase] << field.decoded
rescue Mail::Field::IncompleteParseError
# Never mind, move on to the next header
end
end
end
end
#
# Return the recipient domain for this message
#
def recipient_domain
rcpt_to&.split("@")&.last
end
#
# Create a new item in the message queue for this message
#
def add_to_message_queue(**options)
QueuedMessage.create!({
message: self,
server_id: @database.server_id,
batch_key: batch_key,
domain: recipient_domain,
route_id: route_id
}.merge(options))
end
#
# Return a suitable batch key for this message
#
def batch_key
case scope
when "outgoing"
key = "outgoing-"
key += recipient_domain.to_s
when "incoming"
key = "incoming-"
key += "rt:#{route_id}-ep:#{endpoint_id}-#{endpoint_type}"
else
key = nil
end
key
end
#
# Return the queued message
#
def queued_message
@queued_message ||= id ? QueuedMessage.where(message_id: id, server_id: @database.server_id).first : nil
end
#
# Return the spam status
#
def spam_status
return "NotChecked" unless inspected
spam ? "Spam" : "NotSpam"
end
#
# Has this message been held?
#
def held?
status == "Held"
end
#
# Does this message have our DKIM header yet?
#
def has_outgoing_headers?
!!(raw_headers =~ /^X-Postal-MsgID:/i)
end
#
# Add dkim header
#
def add_outgoing_headers
headers = []
if domain
dkim = DKIMHeader.new(domain, raw_message)
headers << dkim.dkim_header
end
headers << "X-Postal-MsgID: #{token}"
append_headers(*headers)
end
#
# Append a header to the existing headers
#
def append_headers(*headers)
new_headers = headers.join("\r\n")
new_headers = "#{new_headers}\r\n#{raw_headers}"
@database.update(raw_table, { data: new_headers }, where: { id: raw_headers_id })
@raw_headers = new_headers
@raw_message = nil
@headers = nil
end
#
# Return a suitable
#
def webhook_hash
@webhook_hash ||= {
id: id,
token: token,
direction: scope,
message_id: message_id,
to: rcpt_to,
from: mail_from,
subject: subject,
timestamp: timestamp.to_f,
spam_status: spam_status,
tag: tag
}
end
#
# Mark this message as bounced
#
def bounce!(bounce_message)
create_delivery("Bounced", details: "We've received a bounce message for this e-mail. See for details.")
WebhookRequest.trigger(server, "MessageBounced", {
original_message: webhook_hash,
bounce: bounce_message.webhook_hash
})
end
#
# Should bounces be sent for this message?
#
def send_bounces?
!bounce && mail_from.present?
end
#
# Add a load for this message
#
def create_load(request)
update("loaded" => Time.now.to_f) if loaded.nil?
database.insert(:loads, { message_id: id, ip_address: request.ip, user_agent: request.user_agent, timestamp: Time.now.to_f })
WebhookRequest.trigger(server, "MessageLoaded", {
message: webhook_hash,
ip_address: request.ip,
user_agent: request.user_agent
})
end
#
# Create a new link
#
def create_link(url)
hash = Digest::SHA1.hexdigest(url.to_s)
token = SecureRandom.alphanumeric(16)
database.insert(:links, { message_id: id, hash: hash, url: url, timestamp: Time.now.to_f, token: token })
token
end
#
# Return a message object that this message is a reply to
#
def original_messages
return nil unless bounce
other_message_ids = raw_message.scan(/\X-Postal-MsgID:\s*([a-z0-9]+)/i).flatten
if other_message_ids.empty?
[]
else
database.messages(where: { token: other_message_ids })
end
end
#
# Was thsi message sent to a return path?
#
def rcpt_to_return_path?
!!(rcpt_to =~ /@#{Regexp.escape(Postal::Config.dns.custom_return_path_prefix)}\./)
end
#
# Inspect this message
#
def inspect_message
result = MessageInspection.scan(self, scope&.to_sym)
# Update the messages table with the results of our inspection
update(inspected: true, spam_score: result.spam_score, threat: result.threat, threat_details: result.threat_message)
# Add any spam details into the spam checks database
database.insert_multi(:spam_checks, [:message_id, :code, :score, :description], result.spam_checks.map { |d| [id, d.code, d.score, d.description] })
# Return the result
result
end
#
# Return all spam checks for this message
#
def spam_checks
@spam_checks ||= database.select(:spam_checks, where: { message_id: id })
end
#
# Cancel the hold on this message
#
def cancel_hold
return unless status == "Held"
create_delivery("HoldCancelled", details: "The hold on this message has been removed without action.")
end
#
# Parse the contents of this message
#
def parse_content
parse_result = Postal::MessageParser.new(self)
if parse_result.actioned?
# Somethign was changed, update the raw message
@database.update(raw_table, { data: parse_result.new_body }, where: { id: raw_body_id })
@database.update(raw_table, { data: parse_result.new_headers }, where: { id: raw_headers_id })
@raw = parse_result.new_body
@raw_headers = parse_result.new_headers
@raw_message = nil
end
update("parsed" => 1, "tracked_links" => parse_result.tracked_links, "tracked_images" => parse_result.tracked_images)
end
#
# Has this message been parsed?
#
def parsed?
parsed == 1
end
#
# Should this message be parsed?
#
def should_parse?
parsed? == false && headers["x-amp"] != "skip"
end
private
def _update
@database.update("messages", @attributes.except(:id), where: { id: @attributes["id"] })
end
def _create(queue: true)
self.timestamp = Time.now.to_f if timestamp.blank?
self.status = "Pending" if status.blank?
self.token = SecureRandom.alphanumeric(16) if token.blank?
last_id = @database.insert("messages", @attributes.except(:id))
@attributes["id"] = last_id
@database.statistics.increment_all(timestamp, scope)
Statistic.global.increment!(:total_messages)
Statistic.global.increment!("total_#{scope}".to_sym)
add_to_message_queue if queue
end
def mail
# This version of mail is only used for accessing the bodies.
@mail ||= raw_message? ? Mail.new(raw_message) : nil
end
end
end
end
================================================
FILE: lib/postal/message_db/migration.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Migration
def initialize(database)
@database = database
end
def up
end
def self.run(database, start_from: database.schema_version, silent: false)
files = Dir[Rails.root.join("lib", "postal", "message_db", "migrations", "*.rb")]
files = files.map do |f|
id, name = f.split("/").last.split("_", 2)
[id.to_i, name]
end.sort_by(&:first)
latest_version = files.last.first
if latest_version <= start_from
puts "Nothing to do" unless silent
return false
end
unless silent
puts "\e[32mMigrating #{database.database_name} from version #{start_from} => #{files.last.first}\e[0m"
end
files.each do |version, file|
klass_name = file.gsub(/\.rb\z/, "").camelize
next if start_from >= version
puts "\e[45m++ Migrating #{klass_name} (#{version})\e[0m" unless silent
require "postal/message_db/migrations/#{version.to_s.rjust(2, '0')}_#{file}"
klass = Postal::MessageDB::Migrations.const_get(klass_name)
instance = klass.new(database)
instance.up
database.insert(:migrations, version: version)
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/01_create_migrations.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateMigrations < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:migrations,
columns: {
version: "int(11) NOT NULL"
},
primary_key: "`version`")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/02_create_messages.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateMessages < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:messages,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
token: "varchar(255) DEFAULT NULL",
scope: "varchar(10) DEFAULT NULL",
rcpt_to: "varchar(255) DEFAULT NULL",
mail_from: "varchar(255) DEFAULT NULL",
subject: "varchar(255) DEFAULT NULL",
message_id: "varchar(255) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL",
route_id: "int(11) DEFAULT NULL",
domain_id: "int(11) DEFAULT NULL",
credential_id: "int(11) DEFAULT NULL",
status: "varchar(255) DEFAULT NULL",
held: "tinyint(1) DEFAULT 0",
size: "varchar(255) DEFAULT NULL",
last_delivery_attempt: "decimal(18,6) DEFAULT NULL",
raw_table: "varchar(255) DEFAULT NULL",
raw_body_id: "int(11) DEFAULT NULL",
raw_headers_id: "int(11) DEFAULT NULL",
inspected: "tinyint(1) DEFAULT 0",
spam: "tinyint(1) DEFAULT 0",
spam_score: "decimal(8,2) DEFAULT 0",
threat: "tinyint(1) DEFAULT 0",
threat_details: "varchar(255) DEFAULT NULL",
bounce: "tinyint(1) DEFAULT 0",
bounce_for_id: "int(11) DEFAULT 0",
tag: "varchar(255) DEFAULT NULL",
loaded: "decimal(18,6) DEFAULT NULL",
clicked: "decimal(18,6) DEFAULT NULL",
received_with_ssl: "tinyint(1) DEFAULT NULL"
},
indexes: {
on_message_id: "`message_id`(8)",
on_token: "`token`(6)",
on_bounce_for_id: "`bounce_for_id`",
on_held: "`held`",
on_scope_and_status: "`scope`(1), `spam`, `status`(6), `timestamp`",
on_scope_and_tag: "`scope`(1), `spam`, `tag`(8), `timestamp`",
on_scope_and_spam: "`scope`(1), `spam`, `timestamp`",
on_scope_and_thr_status: "`scope`(1), `threat`, `status`(6), `timestamp`",
on_scope_and_threat: "`scope`(1), `threat`, `timestamp`",
on_rcpt_to: "`rcpt_to`(12), `timestamp`",
on_mail_from: "`mail_from`(12), `timestamp`",
on_raw_table: "`raw_table`(14)"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/03_create_deliveries.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateDeliveries < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:deliveries,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
message_id: "int(11) DEFAULT NULL",
status: "varchar(255) DEFAULT NULL",
code: "int(11) DEFAULT NULL",
output: "varchar(512) DEFAULT NULL",
details: "varchar(512) DEFAULT NULL",
sent_with_ssl: "tinyint(1) DEFAULT 0",
log_id: "varchar(100) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL"
},
indexes: {
on_message_id: "`message_id`"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/04_create_live_stats.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateLiveStats < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:live_stats,
columns: {
type: "varchar(20) NOT NULL",
minute: "int(11) NOT NULL",
count: "int(11) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL"
},
primary_key: "`minute`, `type`(8)")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/05_create_raw_message_sizes.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateRawMessageSizes < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:raw_message_sizes,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
table_name: "varchar(255) DEFAULT NULL",
size: "bigint DEFAULT NULL"
},
indexes: {
on_table_name: "`table_name`(14)"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/06_create_clicks.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateClicks < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:clicks,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
message_id: "int(11) DEFAULT NULL",
link_id: "int(11) DEFAULT NULL",
ip_address: "varchar(255) DEFAULT NULL",
country: "varchar(255) DEFAULT NULL",
city: "varchar(255) DEFAULT NULL",
user_agent: "varchar(255) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL"
},
indexes: {
on_message_id: "`message_id`",
on_link_id: "`link_id`"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/07_create_loads.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateLoads < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:loads,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
message_id: "int(11) DEFAULT NULL",
ip_address: "varchar(255) DEFAULT NULL",
country: "varchar(255) DEFAULT NULL",
city: "varchar(255) DEFAULT NULL",
user_agent: "varchar(255) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL"
},
indexes: {
on_message_id: "`message_id`"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/08_create_stats.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateStats < Postal::MessageDB::Migration
def up
[:hourly, :daily, :monthly, :yearly].each do |table_name|
@database.provisioner.create_table("stats_#{table_name}",
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
time: "int(11) DEFAULT NULL",
incoming: "bigint DEFAULT NULL",
outgoing: "bigint DEFAULT NULL",
spam: "bigint DEFAULT NULL",
bounces: "bigint DEFAULT NULL",
held: "bigint DEFAULT NULL"
},
unique_indexes: {
on_time: "`time`"
})
end
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/09_create_links.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateLinks < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:links,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
message_id: "int(11) DEFAULT NULL",
token: "varchar(255) DEFAULT NULL",
hash: "varchar(255) DEFAULT NULL",
url: "varchar(255) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL"
},
indexes: {
on_message_id: "`message_id`",
on_token: "`token`(8)"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/10_create_spam_checks.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateSpamChecks < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:spam_checks,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
message_id: "int(11) DEFAULT NULL",
score: "decimal(8,2) DEFAULT NULL",
code: "varchar(255) DEFAULT NULL",
description: "varchar(255) DEFAULT NULL"
},
indexes: {
on_message_id: "`message_id`",
on_code: "`code`(8)"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/11_add_time_to_deliveries.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class AddTimeToDeliveries < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`deliveries` ADD COLUMN `time` decimal(8,2)")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/12_add_hold_expiry.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class AddHoldExpiry < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `hold_expiry` decimal(18,6)")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/13_add_index_to_message_status.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class AddIndexToMessageStatus < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD INDEX `on_status` (`status`(8)) USING BTREE")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/14_create_suppressions.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateSuppressions < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:suppressions,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
type: "varchar(255) DEFAULT NULL",
address: "varchar(255) DEFAULT NULL",
reason: "varchar(255) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL",
keep_until: "decimal(18,6) DEFAULT NULL"
},
indexes: {
on_address: "`address`(6)",
on_keep_until: "`keep_until`"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/15_create_webhook_requests.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class CreateWebhookRequests < Postal::MessageDB::Migration
def up
@database.provisioner.create_table(:webhook_requests,
columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
uuid: "varchar(255) DEFAULT NULL",
event: "varchar(255) DEFAULT NULL",
attempt: "int(11) DEFAULT NULL",
timestamp: "decimal(18,6) DEFAULT NULL",
status_code: "int(1) DEFAULT NULL",
body: "text DEFAULT NULL",
payload: "text DEFAULT NULL",
will_retry: "tinyint DEFAULT NULL"
},
indexes: {
on_uuid: "`uuid`(8)",
on_event: "`event`(8)",
on_timestamp: "`timestamp`"
})
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/16_add_url_and_hook_to_webhooks.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class AddUrlAndHookToWebhooks < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD COLUMN `url` varchar(255)")
@database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD COLUMN `webhook_id` int(11)")
@database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` ADD INDEX `on_webhook_id` (`webhook_id`) USING BTREE")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/17_add_replaced_link_count_to_messages.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class AddReplacedLinkCountToMessages < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `tracked_links` int(11) DEFAULT 0")
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `tracked_images` int(11) DEFAULT 0")
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `parsed` tinyint DEFAULT 0")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/18_add_endpoints_to_messages.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class AddEndpointsToMessages < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `endpoint_id` int(11), ADD COLUMN `endpoint_type` varchar(255)")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/19_convert_database_to_utf8mb4.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class ConvertDatabaseToUtf8mb4 < Postal::MessageDB::Migration
def up
@database.query("ALTER DATABASE `#{@database.database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`clicks` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`deliveries` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`links` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`live_stats` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`loads` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`messages` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`migrations` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`raw_message_sizes` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`spam_checks` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`stats_daily` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`stats_hourly` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`stats_monthly` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`stats_yearly` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`suppressions` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
@database.query("ALTER TABLE `#{@database.database_name}`.`webhook_requests` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/migrations/20_increase_links_url_size.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
module Migrations
class IncreaseLinksUrlSize < Postal::MessageDB::Migration
def up
@database.query("ALTER TABLE `#{@database.database_name}`.`links` MODIFY `url` TEXT")
end
end
end
end
end
================================================
FILE: lib/postal/message_db/provisioner.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Provisioner
def initialize(database)
@database = database
end
#
# Provisions a new database
#
def provision
drop
create
migrate(silent: true)
end
#
# Migrate this database
#
def migrate(start_from: @database.schema_version, silent: false)
Postal::MessageDB::Migration.run(@database, start_from: start_from, silent: silent)
end
#
# Does a database already exist?
#
def exists?
!!@database.query("SELECT schema_name FROM `information_schema`.`schemata` WHERE schema_name = '#{@database.database_name}'").first
end
#
# Creates a new empty database
#
def create
@database.query("CREATE DATABASE `#{@database.database_name}` CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;")
true
rescue Mysql2::Error => e
e.message =~ /database exists/ ? false : raise
end
#
# Drops the whole message database
#
def drop
@database.query("DROP DATABASE `#{@database.database_name}`;")
true
rescue Mysql2::Error => e
e.message =~ /doesn't exist/ ? false : raise
end
#
# Create a new table
#
def create_table(table_name, options)
@database.query(create_table_query(table_name, options))
end
#
# Drop a table
#
def drop_table(table_name)
@database.query("DROP TABLE `#{@database.database_name}`.`#{table_name}`")
end
#
# Clean the database. This really only useful in development & testing
# environment and can be quite dangerous in production.
#
def clean
%w[clicks deliveries links live_stats loads messages
raw_message_sizes spam_checks stats_daily stats_hourly
stats_monthly stats_yearly suppressions webhook_requests].each do |table|
@database.query("TRUNCATE `#{@database.database_name}`.`#{table}`")
end
end
#
# Creates a new empty raw message table for the given date. Returns nothing.
#
def create_raw_table(table)
@database.query(create_table_query(table, columns: {
id: "int(11) NOT NULL AUTO_INCREMENT",
data: "longblob DEFAULT NULL",
next: "int(11) DEFAULT NULL"
}))
@database.query("INSERT INTO `#{@database.database_name}`.`raw_message_sizes` (table_name, size) VALUES ('#{table}', 0)")
rescue Mysql2::Error => e
# Don't worry if the table already exists, another thread has already run this code.
raise unless e.message =~ /already exists/
end
#
# Return a list of raw message tables that are older than the given date
#
def raw_tables(max_age = 30)
earliest_date = max_age ? Time.now.utc.to_date - max_age : nil
[].tap do |tables|
@database.query("SHOW TABLES FROM `#{@database.database_name}` LIKE 'raw-%'").each do |tbl|
tbl_name = tbl.to_a.first.last
date = Date.parse(tbl_name.gsub(/\Araw-/, ""))
if earliest_date.nil? || date < earliest_date
tables << tbl_name
end
end
end.sort
end
#
# Tidy all messages
#
def remove_raw_tables_older_than(max_age = 30)
raw_tables(max_age).each do |table|
remove_raw_table(table)
end
end
#
# Remove a raw message table
#
def remove_raw_table(table)
@database.query("UPDATE `#{@database.database_name}`.`messages` SET raw_table = NULL, raw_headers_id = NULL, raw_body_id = NULL, size = NULL WHERE raw_table = '#{table}'")
@database.query("DELETE FROM `#{@database.database_name}`.`raw_message_sizes` WHERE table_name = '#{table}'")
drop_table(table)
end
#
# Remove messages from the messages table that are too old to retain
#
def remove_messages(max_age = 60)
time = (Time.now.utc.to_date - max_age.days).to_time.end_of_day
return unless newest_message_to_remove = @database.select(:messages, where: { timestamp: { less_than_or_equal_to: time.to_f } }, limit: 1, order: :id, direction: "DESC", fields: [:id]).first
id = newest_message_to_remove["id"]
@database.query("DELETE FROM `#{@database.database_name}`.`clicks` WHERE `message_id` <= #{id}")
@database.query("DELETE FROM `#{@database.database_name}`.`loads` WHERE `message_id` <= #{id}")
@database.query("DELETE FROM `#{@database.database_name}`.`deliveries` WHERE `message_id` <= #{id}")
@database.query("DELETE FROM `#{@database.database_name}`.`spam_checks` WHERE `message_id` <= #{id}")
@database.query("DELETE FROM `#{@database.database_name}`.`messages` WHERE `id` <= #{id}")
end
#
# Remove raw message tables in order order until size is under the given size (given in MB)
#
def remove_raw_tables_until_less_than_size(size)
tables = raw_tables(nil)
tables_removed = []
until @database.total_size <= size
table = tables.shift
tables_removed << table
remove_raw_table(table)
end
tables_removed
end
private
#
# Build a query to load a table
#
def create_table_query(table_name, options)
String.new.tap do |s|
s << "CREATE TABLE `#{@database.database_name}`.`#{table_name}` ("
s << options[:columns].map do |column_name, column_options|
"`#{column_name}` #{column_options}"
end.join(", ")
if options[:indexes]
s << ", "
s << options[:indexes].map do |index_name, index_options|
"KEY `#{index_name}` (#{index_options}) USING BTREE"
end.join(", ")
end
if options[:unique_indexes]
s << ", "
s << options[:unique_indexes].map do |index_name, index_options|
"UNIQUE KEY `#{index_name}` (#{index_options})"
end.join(", ")
end
if options[:primary_key]
s << ", PRIMARY KEY (#{options[:primary_key]})"
else
s << ", PRIMARY KEY (`id`)"
end
s << ") ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;"
end
end
end
end
end
================================================
FILE: lib/postal/message_db/statistics.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Statistics
def initialize(database)
@database = database
end
STATS_GAPS = { hourly: :hour, daily: :day, monthly: :month, yearly: :year }.freeze
COUNTERS = [:incoming, :outgoing, :spam, :bounces, :held].freeze
#
# Increment an appropriate counter
#
def increment_one(type, field, time = Time.now)
time = time.utc
initial_values = COUNTERS.map do |c|
field.to_sym == c ? 1 : 0
end
time_i = time.send("beginning_of_#{STATS_GAPS[type]}").utc.to_i
sql_query = "INSERT INTO `#{@database.database_name}`.`stats_#{type}` (time, #{COUNTERS.join(', ')})"
sql_query << " VALUES (#{time_i}, #{initial_values.join(', ')})"
sql_query << " ON DUPLICATE KEY UPDATE #{field} = #{field} + 1"
@database.query(sql_query)
end
#
# Increment all stats counters
#
def increment_all(time, field)
STATS_GAPS.each_key do |type|
increment_one(type, field, time)
end
end
#
# Get a statistic (or statistics)
#
def get(type, counters, start_date = Time.now, quantity = 10)
start_date = start_date.utc
items = quantity.times.each_with_object({}) do |i, hash|
hash[(start_date - i.send(STATS_GAPS[type])).send("beginning_of_#{STATS_GAPS[type]}").utc] = counters.each_with_object({}) do |c, h|
h[c] = 0
end
end
@database.select("stats_#{type}", where: { time: items.keys.map(&:to_i) }, fields: [:time] | counters).each do |data|
time = Time.zone.at(data.delete("time"))
data.each do |key, value|
items[time][key.to_sym] = value
end
end
items.to_a.reverse
end
end
end
end
================================================
FILE: lib/postal/message_db/suppression_list.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class SuppressionList
def initialize(database)
@database = database
end
def add(type, address, options = {})
keep_until = (options[:days] || Postal::Config.postal.default_suppression_list_automatic_removal_days).days.from_now.to_f
if existing = @database.select("suppressions", where: { type: type, address: address }, limit: 1).first
reason = options[:reason] || existing["reason"]
@database.update("suppressions", { reason: reason, keep_until: keep_until }, where: { id: existing["id"] })
else
@database.insert("suppressions", { type: type, address: address, reason: options[:reason], timestamp: Time.now.to_f, keep_until: keep_until })
end
true
end
def get(type, address)
@database.select("suppressions", where: { type: type, address: address, keep_until: { greater_than_or_equal_to: Time.now.to_f } }, limit: 1).first
end
def all_with_pagination(page)
@database.select_with_pagination(:suppressions, page, order: :timestamp, direction: "desc")
end
def remove(type, address)
@database.delete("suppressions", where: { type: type, address: address }).positive?
end
def prune
@database.delete("suppressions", where: { keep_until: { less_than: Time.now.to_f } }) || 0
end
end
end
end
================================================
FILE: lib/postal/message_db/webhooks.rb
================================================
# frozen_string_literal: true
module Postal
module MessageDB
class Webhooks
def initialize(database)
@database = database
end
def record(attributes = {})
@database.insert(:webhook_requests, attributes)
end
def list(page = 1)
result = @database.select_with_pagination(:webhook_requests, page, order: :timestamp, direction: "desc")
result[:records] = result[:records].map { |i| Request.new(i) }
result
end
def find(uuid)
request = @database.select(:webhook_requests, where: { uuid: uuid }).first || raise(RequestNotFound, "No request found with UUID '#{uuid}'")
Request.new(request)
end
def prune
return unless last = @database.select(:webhook_requests, where: { timestamp: { less_than: 10.days.ago.to_f } }, order: "timestamp", direction: "desc", limit: 1, fields: ["id"]).first
@database.delete(:webhook_requests, where: { id: { less_than_or_equal_to: last["id"] } })
end
class RequestNotFound < Postal::Error
end
class Request
def initialize(attributes)
@attributes = attributes
end
def [](name)
@attributes[name.to_s]
end
def timestamp
Time.zone.at(@attributes["timestamp"])
end
def event
@attributes["event"]
end
def status_code
@attributes["status_code"]
end
def url
@attributes["url"]
end
def uuid
@attributes["uuid"]
end
def payload
@attributes["payload"]
end
def pretty_payload
@pretty_payload ||= begin
json = JSON.parse(payload)
JSON.pretty_unparse(json)
end
end
def body
@attributes["body"]
end
def attempt
@attributes["attempt"]
end
def will_retry?
@attributes["will_retry"] == 1
end
end
end
end
end
================================================
FILE: lib/postal/message_inspection.rb
================================================
# frozen_string_literal: true
module Postal
class MessageInspection
attr_reader :message
attr_reader :scope
attr_reader :spam_checks
attr_accessor :threat
attr_accessor :threat_message
def initialize(message, scope)
@message = message
@scope = scope
@spam_checks = []
@threat = false
end
def spam_score
return 0 if @spam_checks.empty?
@spam_checks.sum(&:score)
end
def scan
MessageInspector.inspectors.each do |inspector|
inspector.inspect_message(self)
end
end
class << self
def scan(message, scope)
inspection = new(message, scope)
inspection.scan
inspection
end
end
end
end
================================================
FILE: lib/postal/message_inspector.rb
================================================
# frozen_string_literal: true
module Postal
class MessageInspector
def initialize(config)
@config = config
end
# Inspect a message and update the inspection with the results
# as appropriate.
def inspect_message(message, scope, inspection)
end
private
def logger
Postal.logger
end
class << self
# Return an array of all inspectors that are available for this
# installation.
def inspectors
[].tap do |inspectors|
if Postal::Config.rspamd.enabled?
inspectors << MessageInspectors::Rspamd.new(Postal::Config.rspamd)
elsif Postal::Config.spamd.enabled?
inspectors << MessageInspectors::SpamAssassin.new(Postal::Config.spamd)
end
if Postal::Config.clamav.enabled?
inspectors << MessageInspectors::Clamav.new(Postal::Config.clamav)
end
end
end
end
end
end
================================================
FILE: lib/postal/message_inspectors/clamav.rb
================================================
# frozen_string_literal: true
module Postal
module MessageInspectors
class Clamav < MessageInspector
def inspect_message(inspection)
raw_message = inspection.message.raw_message
data = nil
Timeout.timeout(10) do
tcp_socket = TCPSocket.new(@config.host, @config.port)
tcp_socket.write("zINSTREAM\0")
tcp_socket.write([raw_message.bytesize].pack("N"))
tcp_socket.write(raw_message)
tcp_socket.write([0].pack("N"))
tcp_socket.close_write
data = tcp_socket.read
end
if data && data =~ /\Astream:\s+(.*?)[\s\0]+?/
if ::Regexp.last_match(1).upcase == "OK"
inspection.threat = false
inspection.threat_message = "No threats found"
else
inspection.threat = true
inspection.threat_message = ::Regexp.last_match(1)
end
else
inspection.threat = false
inspection.threat_message = "Could not scan message"
end
rescue Timeout::Error
inspection.threat = false
inspection.threat_message = "Timed out scanning for threats"
rescue StandardError => e
logger.error "Error talking to clamav: #{e.class} (#{e.message})"
logger.error e.backtrace[0, 5]
inspection.threat = false
inspection.threat_message = "Error when scanning for threats"
ensure
begin
tcp_socket.close
rescue StandardError
nil
end
end
end
end
end
================================================
FILE: lib/postal/message_inspectors/rspamd.rb
================================================
# frozen_string_literal: true
require "net/http"
module Postal
module MessageInspectors
class Rspamd < MessageInspector
class Error < StandardError
end
def inspect_message(inspection)
response = request(inspection.message, inspection.scope)
response = JSON.parse(response.body)
return unless response["symbols"].is_a?(Hash)
response["symbols"].each_value do |symbol|
next if symbol["description"].blank?
inspection.spam_checks << SpamCheck.new(symbol["name"], symbol["score"], symbol["description"])
end
rescue Error => e
inspection.spam_checks << SpamCheck.new("ERROR", 0, e.message)
end
private
def request(message, scope)
http = Net::HTTP.new(@config.host, @config.port)
http.use_ssl = true if @config.ssl
http.read_timeout = 10
http.open_timeout = 10
raw_message = message.raw_message
request = Net::HTTP::Post.new("/checkv2")
request.body = raw_message
request["Content-Length"] = raw_message.bytesize.to_s
request["Password"] = @config.password if @config.password
request["Flags"] = @config.flags if @config.flags
request["User-Agent"] = "Postal"
request["Deliver-To"] = message.rcpt_to
request["From"] = message.mail_from
request["Rcpt"] = message.rcpt_to
request["Queue-Id"] = message.token
if scope == :outgoing
request["User"] = ""
# We don't actually know the IP but an empty input here will
# still trigger rspamd to treat this as an outbound email
# and disable certain checks.
# https://rspamd.com/doc/tutorials/scanning_outbound.html
request["Ip"] = ""
end
response = nil
begin
response = http.request(request)
rescue StandardError => e
logger.error "Error talking to rspamd: #{e.class} (#{e.message})"
logger.error e.backtrace[0, 5]
raise Error, "Error when scanning with rspamd (#{e.class})"
end
unless response.is_a?(Net::HTTPOK)
logger.info "Got #{response.code} status from rspamd, wanted 200"
raise Error, "Error when scanning with rspamd (got #{response.code})"
end
response
end
end
end
end
================================================
FILE: lib/postal/message_inspectors/spam_assassin.rb
================================================
# frozen_string_literal: true
module Postal
module MessageInspectors
class SpamAssassin < MessageInspector
EXCLUSIONS = {
outgoing: ["NO_RECEIVED", "NO_RELAYS", "ALL_TRUSTED", "FREEMAIL_FORGED_REPLYTO", "RDNS_DYNAMIC", "CK_HELO_GENERIC", /^SPF_/, /^HELO_/, /DKIM_/, /^RCVD_IN_/],
incoming: []
}.freeze
def inspect_message(inspection)
data = nil
raw_message = inspection.message.raw_message
Timeout.timeout(15) do
tcp_socket = TCPSocket.new(@config.host, @config.port)
tcp_socket.write("REPORT SPAMC/1.2\r\n")
tcp_socket.write("Content-length: #{raw_message.bytesize}\r\n")
tcp_socket.write("\r\n")
tcp_socket.write(raw_message)
tcp_socket.close_write
data = tcp_socket.read
end
spam_checks = []
total = 0.0
rules = data ? data.split(/^---(.*)\r?\n/).last.split(/\r?\n/) : []
while line = rules.shift
if line =~ /\A([- ]?[\d.]+)\s+(\w+)\s+(.*)/
total += ::Regexp.last_match(1).to_f
spam_checks << SpamCheck.new(::Regexp.last_match(2), ::Regexp.last_match(1).to_f, ::Regexp.last_match(3))
else
spam_checks.last.description << (" " + line.strip)
end
end
checks = spam_checks.reject { |s| EXCLUSIONS[inspection.scope].include?(s.code) }
checks.each do |check|
inspection.spam_checks << check
end
rescue Timeout::Error
inspection.spam_checks << SpamCheck.new("TIMEOUT", 0, "Timed out when scanning for spam")
rescue StandardError => e
logger.error "Error talking to spamd: #{e.class} (#{e.message})"
logger.error e.backtrace[0, 5]
inspection.spam_checks << SpamCheck.new("ERROR", 0, "Error when scanning for spam")
ensure
begin
tcp_socket.close
rescue StandardError
nil
end
end
end
end
end
================================================
FILE: lib/postal/message_parser.rb
================================================
# frozen_string_literal: true
module Postal
class MessageParser
URL_REGEX = /(?(?https?):\/\/(?[A-Za-z0-9\-.:]+)(?\/[A-Za-z0-9.\/+?&\-_%=~:;()\[\]#]*)?+)/
def initialize(message)
@message = message
@actioned = false
@tracked_links = 0
@tracked_images = 0
@domain = @message.server.track_domains.where(domain: @message.domain, dns_status: "OK").first
return unless @domain
@parsed_output = generate.split("\r\n\r\n", 2)
end
attr_reader :tracked_links
attr_reader :tracked_images
def actioned?
@actioned || @tracked_links.positive? || @tracked_images.positive?
end
def new_body
@parsed_output[1]
end
def new_headers
@parsed_output[0]
end
private
def generate
@mail = Mail.new(@message.raw_message)
@original_message = @message.raw_message
if @mail.parts.empty?
if @mail.mime_type
if @mail.mime_type =~ /text\/plain/
@mail.body = parse(@mail.body.decoded.dup, :text)
@mail.content_transfer_encoding = nil
@mail.charset = "UTF-8"
elsif @mail.mime_type =~ /text\/html/
@mail.body = parse(@mail.body.decoded.dup, :html)
@mail.content_transfer_encoding = nil
@mail.charset = "UTF-8"
end
end
else
parse_parts(@mail.parts)
end
@mail.to_s
rescue StandardError => e
raise if Rails.env.development?
if defined?(Sentry)
Sentry.capture_exception(e)
end
@actioned = false
@tracked_links = 0
@tracked_images = 0
@original_message
end
def parse_parts(parts)
parts.each do |part|
case part.content_type
when /text\/html/
part.body = parse(part.body.decoded.dup, :html)
part.content_transfer_encoding = nil
part.charset = "UTF-8"
when /text\/plain/
part.body = parse(part.body.decoded.dup, :text)
part.content_transfer_encoding = nil
part.charset = "UTF-8"
when /multipart\/(alternative|related)/
unless part.parts.empty?
parse_parts(part.parts)
end
end
end
end
def parse(part, type = nil)
if @domain.track_clicks?
part = insert_links(part, type)
end
if @domain.track_loads? && type == :html
part = insert_tracking_image(part)
end
part
end
def insert_links(part, type = nil)
if type == :text
part.gsub!(/(#{URL_REGEX})(?=\s|$)/) do
if track_domain?($~[:domain])
@tracked_links += 1
url = $~[:url]
while url =~ /[^\w]$/
theend = url.size - 2
url = url[0..theend]
end
token = @message.create_link(url)
"#{domain}/#{@message.server.token}/#{token}"
else
::Regexp.last_match(0)
end
end
end
if type == :html
part.gsub!(/href=(['"])(#{URL_REGEX})['"]/) do
if track_domain?($~[:domain])
@tracked_links += 1
url = CGI.unescapeHTML($~[:url])
token = @message.create_link(url)
"href='#{domain}/#{@message.server.token}/#{token}'"
else
::Regexp.last_match(0)
end
end
end
part.gsub!(/(https?)\+notrack:\/\//) do
@actioned = true
"#{::Regexp.last_match(1)}://"
end
part
end
def insert_tracking_image(part)
@tracked_images += 1
container = "
"
if part =~ /<\/body>/
part.gsub("
The page you were looking for doesn't exist.
You may have mistyped the address or the page may have moved.
If you are the application owner check the logs for more information.
", "#{container}")
else
part + container
end
end
def domain
"#{@domain.use_ssl? ? 'https' : 'http'}://#{@domain.full_name}"
end
def track_domain?(domain)
!@domain.excluded_click_domains_array.include?(domain)
end
end
end
================================================
FILE: lib/postal/signer.rb
================================================
# frozen_string_literal: true
require "base64"
module Postal
class Signer
# Create a new Signer
#
# @param [OpenSSL::PKey::RSA] private_key The private key to use for signing
# @return [Signer]
def initialize(private_key)
@private_key = private_key
end
# Return the private key
#
# @return [OpenSSL::PKey::RSA]
attr_reader :private_key
# Return the public key for the private key
#
# @return [OpenSSL::PKey::RSA]
def public_key
@private_key.public_key
end
# Sign the given data
#
# @param [String] data The data to sign
# @return [String] The signature
def sign(data)
private_key.sign(OpenSSL::Digest.new("SHA256"), data)
end
# Sign the given data and return a Base64-encoded signature
#
# @param [String] data The data to sign
# @return [String] The Base64-encoded signature
def sign64(data)
Base64.strict_encode64(sign(data))
end
# Return a JWK for the private key
#
# @return [JWT::JWK] The JWK
def jwk
@jwk ||= JWT::JWK.new(private_key, { use: "sig", alg: "RS256" })
end
# Sign the given data using SHA1 (for legacy use)
#
# @param [String] data The data to sign
# @return [String] The signature
def sha1_sign(data)
private_key.sign(OpenSSL::Digest.new("SHA1"), data)
end
# Sign the given data using SHA1 (for legacy use) and return a Base64-encoded string
#
# @param [String] data The data to sign
# @return [String] The signature
def sha1_sign64(data)
Base64.strict_encode64(sha1_sign(data))
end
end
end
================================================
FILE: lib/postal/spam_check.rb
================================================
# frozen_string_literal: true
module Postal
class SpamCheck
attr_reader :code, :score, :description
def initialize(code, score, description = nil)
@code = code
@score = score
@description = description
end
def to_hash
{
code: code,
score: score,
description: description
}
end
end
end
================================================
FILE: lib/postal/yaml_config_exporter.rb
================================================
# frozen_string_literal: true
require "konfig/exporters/abstract"
module Postal
class YamlConfigExporter < Konfig::Exporters::Abstract
def export
contents = []
contents << "version: 2"
contents << ""
@schema.groups.each do |group_name, group|
contents << "#{group_name}:"
group.attributes.each do |name, attr|
contents << " # #{attr.description}"
if attr.array?
if attr.default.blank?
contents << " #{name}: []"
else
contents << " #{name}:"
attr.transform(attr.default).each do |d|
contents << " - #{d}"
end
end
else
contents << " #{name}: #{attr.default}"
end
end
contents << ""
end
contents.join("\n")
end
end
end
================================================
FILE: lib/postal.rb
================================================
# frozen_string_literal: true
module Postal
end
================================================
FILE: lib/tasks/.keep
================================================
================================================
FILE: lib/tasks/auto_annotate_models.rake
================================================
# NOTE: only doing this in development as some production environments (Heroku)
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development?
require 'annotate'
task :set_annotation_options do
# You can override any of these by setting an environment variable of the
# same name.
Annotate.set_defaults(
'active_admin' => 'false',
'additional_file_patterns' => [],
'routes' => 'false',
'models' => 'true',
'position_in_routes' => 'before',
'position_in_class' => 'before',
'position_in_test' => 'before',
'position_in_fixture' => 'before',
'position_in_factory' => 'before',
'position_in_serializer' => 'before',
'show_foreign_keys' => 'true',
'show_complete_foreign_keys' => 'false',
'show_indexes' => 'true',
'simple_indexes' => 'false',
'model_dir' => 'app/models',
'root_dir' => '',
'include_version' => 'false',
'require' => '',
'exclude_tests' => 'false',
'exclude_fixtures' => 'false',
'exclude_factories' => 'false',
'exclude_serializers' => 'false',
'exclude_scaffolds' => 'true',
'exclude_controllers' => 'true',
'exclude_helpers' => 'true',
'exclude_sti_subclasses' => 'false',
'ignore_model_sub_dir' => 'false',
'ignore_columns' => nil,
'ignore_routes' => nil,
'ignore_unknown_models' => 'false',
'hide_limit_column_types' => 'integer,bigint,boolean',
'hide_default_column_types' => 'json,jsonb,hstore',
'skip_on_db_migrate' => 'false',
'format_bare' => 'true',
'format_rdoc' => 'false',
'format_yard' => 'false',
'format_markdown' => 'false',
'sort' => 'false',
'force' => 'false',
'frozen' => 'false',
'classified_sort' => 'true',
'trace' => 'false',
'wrapper_open' => nil,
'wrapper_close' => nil,
'with_comment' => 'true'
)
end
Annotate.load_tasks
end
================================================
FILE: lib/tasks/postal.rake
================================================
# frozen_string_literal: true
namespace :postal do
desc "Run all migrations on message databases"
task migrate_message_databases: :environment do
Server.all.each do |server|
puts "Running migrations for #{server.organization.permalink}/#{server.permalink} (ID: #{server.id})"
server.message_db.provisioner.migrate
end
end
desc "Generate configuration documentation"
task generate_config_docs: :environment do
require "konfig/exporters/env_vars_as_markdown"
FileUtils.mkdir_p("doc/config")
output = Konfig::Exporters::EnvVarsAsMarkdown.new(Postal::ConfigSchema).export
File.write("doc/config/environment-variables.md", output)
output = Postal::YamlConfigExporter.new(Postal::ConfigSchema).export
File.write("doc/config/yaml.yml", output)
end
desc "Generate Helm Environment Variables"
task generate_helm_env_vars: :environment do
puts Postal::HelmConfigExporter.new(Postal::ConfigSchema).export
end
desc "Update the database"
task update: :environment do
mysql = ActiveRecord::Base.connection
if mysql.table_exists?("schema_migrations") &&
mysql.select_all("select * from schema_migrations").any?
puts "Database schema is already loaded. Running migrations with db:migrate"
Rake::Task["db:migrate"].invoke
else
puts "No schema migrations exist. Loading schema with db:schema:load"
Rake::Task["db:schema:load"].invoke
end
end
end
Rake::Task["db:migrate"].enhance do
Rake::Task["postal:migrate_message_databases"].invoke
end
================================================
FILE: lib/tracking_middleware.rb
================================================
# frozen_string_literal: true
class TrackingMiddleware
TRACKING_PIXEL = File.read(Rails.root.join("app", "assets", "images", "tracking_pixel.png"))
def initialize(app = nil)
@app = app
end
def call(env)
unless env["HTTP_X_POSTAL_TRACK_HOST"].to_i == 1
return @app.call(env)
end
request = Rack::Request.new(env)
case request.path
when /\A\/img\/([a-z0-9-]+)\/([a-z0-9-]+)/i
server_token = ::Regexp.last_match(1)
message_token = ::Regexp.last_match(2)
dispatch_image_request(request, server_token, message_token)
when /\A\/([a-z0-9-]+)\/([a-z0-9-]+)/i
server_token = ::Regexp.last_match(1)
link_token = ::Regexp.last_match(2)
dispatch_redirect_request(request, server_token, link_token)
else
[200, {}, ["Hello."]]
end
end
private
def dispatch_image_request(request, server_token, message_token)
message_db = get_message_db_from_server_token(server_token)
if message_db.nil?
return [404, {}, ["Invalid Server Token"]]
end
begin
message = message_db.message(token: message_token)
message.create_load(request)
rescue Postal::MessageDB::Message::NotFound
# This message has been removed, we'll just continue to serve the image
rescue StandardError => e
# Somethign else went wrong. We don't want to stop the image loading though because
# this is our problem. Log this exception though.
Sentry.capture_exception(e) if defined?(Sentry)
end
source_image = request.params["src"]
case source_image
when nil
headers = {}
headers["Content-Type"] = "image/png"
headers["Content-Length"] = TRACKING_PIXEL.bytesize.to_s
[200, headers, [TRACKING_PIXEL]]
when /\Ahttps?:\/\//
response = Postal::HTTP.get(source_image, timeout: 3)
return [404, {}, ["Not found"]] unless response[:code] == 200
headers = {}
headers["Content-Type"] = response[:headers]["content-type"]&.first
headers["Last-Modified"] = response[:headers]["last-modified"]&.first
headers["Cache-Control"] = response[:headers]["cache-control"]&.first
headers["Etag"] = response[:headers]["etag"]&.first
headers["Content-Length"] = response[:body].bytesize.to_s
[200, headers, [response[:body]]]
else
[400, {}, ["Invalid/missing source image"]]
end
end
def dispatch_redirect_request(request, server_token, link_token)
message_db = get_message_db_from_server_token(server_token)
if message_db.nil?
return [404, {}, ["Invalid Server Token"]]
end
link = message_db.select(:links, where: { token: link_token }, limit: 1).first
if link.nil?
return [404, {}, ["Link not found"]]
end
time = Time.now.to_f
if link["message_id"]
message_db.update(:messages, { clicked: time }, where: { id: link["message_id"] })
message_db.insert(:clicks, {
message_id: link["message_id"],
link_id: link["id"],
ip_address: request.ip,
user_agent: request.user_agent,
timestamp: time
})
begin
message_webhook_hash = message_db.message(link["message_id"]).webhook_hash
WebhookRequest.trigger(message_db.server, "MessageLinkClicked", {
message: message_webhook_hash,
url: link["url"],
token: link["token"],
ip_address: request.ip,
user_agent: request.user_agent
})
rescue Postal::MessageDB::Message::NotFound
# If we can't find the message that this link is associated with, we'll just ignore it
# and not trigger any webhooks.
end
end
[307, { "Location" => link["url"] }, ["Redirected to: #{link['url']}"]]
end
def get_message_db_from_server_token(token)
return unless server = ::Server.find_by_token(token)
server.message_db
end
end
================================================
FILE: log/.keep
================================================
================================================
FILE: public/404.html
================================================