diff --git a/drizzle/migrations/0021_romantic_captain_midlands.sql b/drizzle/migrations/0021_romantic_captain_midlands.sql new file mode 100644 index 0000000..4c680f3 --- /dev/null +++ b/drizzle/migrations/0021_romantic_captain_midlands.sql @@ -0,0 +1 @@ +ALTER TABLE "queues" ADD COLUMN "mail_alias" text; \ No newline at end of file diff --git a/drizzle/migrations/meta/0021_snapshot.json b/drizzle/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000..1ee329b --- /dev/null +++ b/drizzle/migrations/meta/0021_snapshot.json @@ -0,0 +1,2024 @@ +{ + "id": "c8a0224e-70d1-40ef-9c42-a6b8f0f9a87d", + "prevId": "fc7c2a7b-8b38-4472-b43d-1556381fb83c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_field_values": { + "name": "custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "custom_field_id": { + "name": "custom_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "custom_field_values_ticket_id_idx": { + "name": "custom_field_values_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_field_values_custom_field_id_idx": { + "name": "custom_field_values_custom_field_id_idx", + "columns": [ + { + "expression": "custom_field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_values_custom_field_id_custom_fields_id_fk": { + "name": "custom_field_values_custom_field_id_custom_fields_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "custom_fields", + "columnsFrom": [ + "custom_field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_field_values_ticket_id_tickets_id_fk": { + "name": "custom_field_values_ticket_id_tickets_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_field_values_cf_id_ticket_id_value_unique": { + "name": "custom_field_values_cf_id_ticket_id_value_unique", + "nullsNotDistinct": false, + "columns": [ + "custom_field_id", + "ticket_id", + "value" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_fields": { + "name": "custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "values": { + "name": "values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_values": { + "name": "max_values", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_config": { + "name": "validation_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_fields_key_unique": { + "name": "custom_fields_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_widgets": { + "name": "dashboard_widgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_id": { + "name": "view_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "widget_type": { + "name": "widget_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"x\":0,\"y\":0,\"w\":4,\"h\":2}'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_widgets_dashboard_id_dashboards_id_fk": { + "name": "dashboard_widgets_dashboard_id_dashboards_id_fk", + "tableFrom": "dashboard_widgets", + "tableTo": "dashboards", + "columnsFrom": [ + "dashboard_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_widgets_view_id_views_id_fk": { + "name": "dashboard_widgets_view_id_views_id_fk", + "tableFrom": "dashboard_widgets", + "tableTo": "views", + "columnsFrom": [ + "view_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboards_team_id_teams_id_fk": { + "name": "dashboards_team_id_teams_id_fk", + "tableFrom": "dashboards", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lifecycles": { + "name": "lifecycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "lifecycles_name_unique": { + "name": "lifecycles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "notifications_user_id_idx": { + "name": "notifications_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_read_idx": { + "name": "notifications_user_read_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_ticket_id_tickets_id_fk": { + "name": "notifications_ticket_id_tickets_id_fk", + "tableFrom": "notifications", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_custom_fields": { + "name": "queue_custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "custom_field_id": { + "name": "custom_field_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "queue_custom_fields_queue_id_queues_id_fk": { + "name": "queue_custom_fields_queue_id_queues_id_fk", + "tableFrom": "queue_custom_fields", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "queue_custom_fields_custom_field_id_custom_fields_id_fk": { + "name": "queue_custom_fields_custom_field_id_custom_fields_id_fk", + "tableFrom": "queue_custom_fields", + "tableTo": "custom_fields", + "columnsFrom": [ + "custom_field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queue_custom_fields_queue_id_custom_field_id_unique": { + "name": "queue_custom_fields_queue_id_custom_field_id_unique", + "nullsNotDistinct": false, + "columns": [ + "queue_id", + "custom_field_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_permissions": { + "name": "queue_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "right_name": { + "name": "right_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "queue_permissions_queue_id_idx": { + "name": "queue_permissions_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "queue_permissions_team_id_idx": { + "name": "queue_permissions_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_permissions_queue_id_queues_id_fk": { + "name": "queue_permissions_queue_id_queues_id_fk", + "tableFrom": "queue_permissions", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_permissions_team_id_teams_id_fk": { + "name": "queue_permissions_team_id_teams_id_fk", + "tableFrom": "queue_permissions", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queue_permissions_queue_team_right_unique": { + "name": "queue_permissions_queue_team_right_unique", + "nullsNotDistinct": false, + "columns": [ + "queue_id", + "team_id", + "right_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_id": { + "name": "lifecycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mail_alias": { + "name": "mail_alias", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queues_lifecycle_id_lifecycles_id_fk": { + "name": "queues_lifecycle_id_lifecycles_id_fk", + "tableFrom": "queues", + "tableTo": "lifecycles", + "columnsFrom": [ + "lifecycle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "queues_team_id_teams_id_fk": { + "name": "queues_team_id_teams_id_fk", + "tableFrom": "queues", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_name_unique": { + "name": "queues_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrips": { + "name": "scrips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_config": { + "name": "condition_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_config": { + "name": "action_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'TransactionCreate'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applicable_trans_types": { + "name": "applicable_trans_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "scrips_queue_id_idx": { + "name": "scrips_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrips_queue_id_queues_id_fk": { + "name": "scrips_queue_id_queues_id_fk", + "tableFrom": "scrips", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scrips_template_id_templates_id_fk": { + "name": "scrips_template_id_templates_id_fk", + "tableFrom": "scrips", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_members_team_id_user_id_unique": { + "name": "team_members_team_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_name_unique": { + "name": "teams_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "templates_queue_id_queues_id_fk": { + "name": "templates_queue_id_queues_id_fk", + "tableFrom": "templates", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ticket_links": { + "name": "ticket_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_ticket_id": { + "name": "target_ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "link_type": { + "name": "link_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "ticket_links_ticket_id_idx": { + "name": "ticket_links_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ticket_links_target_ticket_id_idx": { + "name": "ticket_links_target_ticket_id_idx", + "columns": [ + { + "expression": "target_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ticket_links_ticket_id_tickets_id_fk": { + "name": "ticket_links_ticket_id_tickets_id_fk", + "tableFrom": "ticket_links", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ticket_links_target_ticket_id_tickets_id_fk": { + "name": "ticket_links_target_ticket_id_tickets_id_fk", + "tableFrom": "ticket_links", + "tableTo": "tickets", + "columnsFrom": [ + "target_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ticket_links_creator_id_users_id_fk": { + "name": "ticket_links_creator_id_users_id_fk", + "tableFrom": "ticket_links", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ticket_links_ticket_target_type_unique": { + "name": "ticket_links_ticket_target_type_unique", + "nullsNotDistinct": false, + "columns": [ + "ticket_id", + "target_ticket_id", + "link_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tickets_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tickets_queue_id_idx": { + "name": "tickets_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tickets_queue_id_queues_id_fk": { + "name": "tickets_queue_id_queues_id_fk", + "tableFrom": "tickets", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_owner_id_users_id_fk": { + "name": "tickets_owner_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tickets_team_id_teams_id_fk": { + "name": "tickets_team_id_teams_id_fk", + "tableFrom": "tickets", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tickets_creator_id_users_id_fk": { + "name": "tickets_creator_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction_attachments": { + "name": "transaction_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'application/octet-stream'" + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transaction_attachments_tx_id_idx": { + "name": "transaction_attachments_tx_id_idx", + "columns": [ + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transaction_attachments_transaction_id_transactions_id_fk": { + "name": "transaction_attachments_transaction_id_transactions_id_fk", + "tableFrom": "transaction_attachments", + "tableTo": "transactions", + "columnsFrom": [ + "transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transaction_type": { + "name": "transaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "time_worked_minutes": { + "name": "time_worked_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "transactions_ticket_id_idx": { + "name": "transactions_ticket_id_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transactions_created_at_idx": { + "name": "transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_ticket_id_tickets_id_fk": { + "name": "transactions_ticket_id_tickets_id_fk", + "tableFrom": "transactions", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transactions_creator_id_users_id_fk": { + "name": "transactions_creator_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_permissions": { + "name": "user_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "right_name": { + "name": "right_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_permissions_queue_id_idx": { + "name": "user_permissions_queue_id_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_permissions_user_id_idx": { + "name": "user_permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_permissions_queue_id_queues_id_fk": { + "name": "user_permissions_queue_id_queues_id_fk", + "tableFrom": "user_permissions", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_permissions_user_id_users_id_fk": { + "name": "user_permissions_user_id_users_id_fk", + "tableFrom": "user_permissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_permissions_queue_user_right_unique": { + "name": "user_permissions_queue_user_right_unique", + "nullsNotDistinct": false, + "columns": [ + "queue_id", + "user_id", + "right_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'staff'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'updated'" + }, + "columns": { + "name": "columns", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "views_creator_id_users_id_fk": { + "name": "views_creator_id_users_id_fk", + "tableFrom": "views", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 0c8f3ee..367c0b4 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1781552001000, "tag": "0020_sla_tables", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1781552215621, + "tag": "0021_romantic_captain_midlands", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/config.ts b/src/config.ts index 7eef0a6..273eee5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,12 @@ const configSchema = z.object({ SMTP_FROM: z.string().default('tessera@localhost'), UPLOAD_DIR: z.string().default('./data/uploads'), JWT_SECRET: z.string().default('tessera-dev-secret-change-in-production'), + // Inbound email + MAIL_TRANSPORT: z.enum(['mailtm', 'webhook', 'none']).default('none'), + MAILTM_POLL_SECONDS: z.coerce.number().int().positive().default(30), + MAILTM_ADDRESS: z.string().optional(), + MAILTM_ACCOUNT_ID: z.string().optional(), + MAILTM_TOKEN: z.string().optional(), }); export const config = configSchema.parse(process.env); diff --git a/src/db/schema.ts b/src/db/schema.ts index 623020d..4f5dffe 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -16,6 +16,7 @@ export const queues = pgTable('queues', { description: text('description'), lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id), team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }), + mail_alias: text('mail_alias'), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), }); diff --git a/src/email/mailtm.ts b/src/email/mailtm.ts new file mode 100644 index 0000000..c9c1173 --- /dev/null +++ b/src/email/mailtm.ts @@ -0,0 +1,250 @@ +import { config } from '../config.ts'; +import type { InboundEmail } from './types.ts'; +import type { EmailProcessor } from './processor.ts'; + +interface MailTmAccount { + id: string; + address: string; + password: string; + token?: string; +} + +interface MailTmMessage { + id: string; + msgid: string; + from: { + address: string; + name: string; + }; + to: Array<{ + address: string; + name: string; + }>; + subject: string; + text: string; + html: string; + seen: boolean; + createdAt: string; +} + +const API_BASE = 'https://api.mail.tm'; + +/** + * mail.tm transport: creates a disposable inbox, polls for new messages, + * and feeds them to the EmailProcessor. + */ +export class MailTmTransport { + private account: MailTmAccount | null = null; + private processor: EmailProcessor | null = null; + private intervalId: ReturnType | null = null; + private running = false; + + constructor(processor: EmailProcessor) { + this.processor = processor; + } + + async start(): Promise { + if (this.running) { + return this.account?.address ?? ''; + } + + this.running = true; + + // If account cached in env, reuse it + if (config.MAILTM_ACCOUNT_ID && config.MAILTM_TOKEN) { + this.account = { + id: config.MAILTM_ACCOUNT_ID, + address: config.MAILTM_ADDRESS ?? 'unknown@mail.tm', + password: '', // not needed when reusing token + token: config.MAILTM_TOKEN, + }; + console.log(`[mailtm] Reusing cached inbox: ${this.account.address}`); + } else { + this.account = await this.createAccount(); + console.log(`[mailtm] Created test inbox: ${this.account.address}`); + console.log(`[mailtm] To persist this inbox, set in .env:`); + console.log(` MAILTM_ADDRESS=${this.account.address}`); + console.log(` MAILTM_ACCOUNT_ID=${this.account.id}`); + console.log(` MAILTM_TOKEN=${this.account.token}`); + } + + // Start polling + const seconds = config.MAILTM_POLL_SECONDS; + console.log(`[mailtm] Polling every ${seconds}s — use this address: ${this.account.address}`); + this.poll(); // immediate first poll + this.intervalId = setInterval(() => this.poll(), seconds * 1000); + + return this.account.address; + } + + stop(): void { + this.running = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + console.log('[mailtm] Stopped'); + } + + private async createAccount(): Promise { + // Get available domains first (mail.tm rotates domains) + let domain = 'mail.tm'; + try { + const domainResp = await fetch(`${API_BASE}/domains`); + if (domainResp.ok) { + const data = await domainResp.json() as { 'hydra:member': Array<{ domain: string }> }; + if (data['hydra:member']?.length > 0) { + domain = data['hydra:member'][0]!.domain; + } + } + } catch { + // Fall back to default domain + } + + const username = `tessera-${crypto.randomUUID().slice(0, 8)}`; + const password = crypto.randomUUID(); + + const resp = await fetch(`${API_BASE}/accounts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: `${username}@${domain}`, password }), + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + throw new Error(`mail.tm account creation failed: HTTP ${resp.status} ${body}`); + } + + const account = (await resp.json()) as { id: string; address: string }; + + // Get token + const tokenResp = await fetch(`${API_BASE}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: account.address, password }), + }); + + if (!tokenResp.ok) { + throw new Error(`mail.tm token request failed: HTTP ${tokenResp.status}`); + } + + const tokenData = (await tokenResp.json()) as { token: string }; + + return { + id: account.id, + address: account.address, + password, + token: tokenData.token, + }; + } + + private async fetchMessages(): Promise { + if (!this.account?.token) return []; + + try { + const resp = await fetch(`${API_BASE}/messages`, { + headers: { Authorization: `Bearer ${this.account.token}` }, + }); + + if (!resp.ok) { + // Token might have expired, try to refresh + if (resp.status === 401) { + try { + this.account = await this.createAccount(); + return []; + } catch { + console.error('[mailtm] Failed to refresh account'); + return []; + } + } + return []; + } + + const data = (await resp.json()) as { 'hydra:member': MailTmMessage[] }; + return (data['hydra:member'] ?? []).filter((m) => !m.seen); + } catch (err) { + console.error('[mailtm] Error fetching messages:', err instanceof Error ? err.message : String(err)); + return []; + } + } + + private async getFullMessage(messageId: string): Promise { + if (!this.account?.token) return null; + + try { + const resp = await fetch(`${API_BASE}/messages/${messageId}`, { + headers: { Authorization: `Bearer ${this.account.token}` }, + }); + + if (!resp.ok) return null; + return (await resp.json()) as MailTmMessage; + } catch { + return null; + } + } + + private async markRead(messageId: string): Promise { + if (!this.account?.token) return; + + try { + await fetch(`${API_BASE}/messages/${messageId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${this.account.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ seen: true }), + }); + } catch { + // Best effort + } + } + + private async poll(): Promise { + if (!this.running || !this.processor) return; + + const messages = await this.fetchMessages(); + if (messages.length === 0) return; + + console.log(`[mailtm] Found ${messages.length} new message(s)`); + + for (const summary of messages) { + const full = await this.getFullMessage(summary.id); + if (!full) continue; + + // Build the from display string + const fromName = full.from?.name ?? ''; + const fromAddr = full.from?.address ?? ''; + const fromDisplay = fromName ? `${fromName} <${fromAddr}>` : fromAddr; + + // Build the to display string + const toDisplay = (full.to ?? []) + .map((t) => (t.name ? `${t.name} <${t.address}>` : t.address)) + .join(', '); + + const inbound: InboundEmail = { + from: fromDisplay, + fromAddress: fromAddr, + to: toDisplay || this.account!.address, + subject: full.subject ?? '(no subject)', + bodyText: full.text ?? '', + bodyHtml: full.html, + attachments: [], + messageId: full.msgid ?? full.id, + receivedAt: new Date(full.createdAt ?? Date.now()), + }; + + try { + const result = await this.processor.process(inbound); + console.log(`[mailtm] Processed: ${result.action} — ${result.detail}`); + } catch (err) { + console.error( + `[mailtm] Error processing message ${summary.id}:`, + err instanceof Error ? err.message : String(err), + ); + } + + await this.markRead(summary.id); + } + } +} diff --git a/src/email/processor.ts b/src/email/processor.ts new file mode 100644 index 0000000..bbc6c63 --- /dev/null +++ b/src/email/processor.ts @@ -0,0 +1,172 @@ +import { eq } from 'drizzle-orm'; +import type { Db } from '../db/index.ts'; +import { tickets, transactions, queues, lifecycles } from '../db/schema.ts'; +import { ScripEngine } from '../scrip/engine.ts'; +import { LifecycleValidator } from '../lifecycle/validator.ts'; +import type { LifecycleDefinition } from '../lifecycle/validator.ts'; +import { resolveUser, resolveQueue, matchTicket } from './resolvers.ts'; +import type { InboundEmail, ProcessResult } from './types.ts'; + +/** Tracks recently seen message IDs for dedup */ +const seenMessageIds = new Set(); +const MAX_SEEN_IDS = 2000; + +function isSeen(messageId: string): boolean { + if (seenMessageIds.has(messageId)) return true; + seenMessageIds.add(messageId); + // Prune oldest entries if set grows too large + if (seenMessageIds.size > MAX_SEEN_IDS) { + const iter = seenMessageIds.values(); + for (let i = 0; i < 500; i++) { + const { value, done } = iter.next(); + if (done) break; + seenMessageIds.delete(value!); + } + } + return false; +} + +/** + * Extract the plain from-address from a From header. + * Handles "Alice " and plain "alice@example.com". + */ +function parseAddress(raw: string): string { + const match = raw.match(/<([^>]+)>/); + if (match) return match[1]!.trim().toLowerCase(); + return raw.trim().toLowerCase(); +} + +export class EmailProcessor { + private db: Db; + private scripEngine: ScripEngine; + private lifecycleValidator: LifecycleValidator; + + constructor(db: Db) { + this.db = db; + this.scripEngine = new ScripEngine(db); + this.lifecycleValidator = new LifecycleValidator(); + } + + async process(email: InboundEmail): Promise { + // Dedup by messageId + if (isSeen(email.messageId)) { + console.log(`[email] Skipping duplicate message: ${email.messageId}`); + return { action: 'skipped', detail: `Duplicate messageId: ${email.messageId}` }; + } + + const fromAddress = parseAddress(email.from); + const toAddress = parseAddress(email.to); + + // Resolve user + const { userId, isNew } = await resolveUser(this.db, fromAddress); + if (isNew) { + console.log(`[email] Created stub user for ${fromAddress}`); + } + + // Resolve queue + const { queueId, queueName } = await resolveQueue(this.db, toAddress); + console.log(`[email] Routed to queue "${queueName}"`); + + // Match or create ticket + const matchedTicket = await matchTicket(this.db, email.subject); + + if (matchedTicket) { + // Reply: add Correspond transaction + const [tx] = await this.db + .insert(transactions) + .values({ + ticket_id: matchedTicket.id, + transaction_type: 'Correspond', + data: { + body: email.bodyText || email.bodyHtml || '(no body)', + from: fromAddress, + message_id: email.messageId, + }, + creator_id: userId, + }) + .returning(); + + if (!tx) { + return { action: 'skipped', detail: 'Failed to create transaction' }; + } + + // Update ticket timestamp + await this.db + .update(tickets) + .set({ updated_at: new Date() } as any) + .where(eq(tickets.id, matchedTicket.id)); + + // Fire scrips (e.g. OnCorrespond → auto-reply) + const prepared = await this.scripEngine.prepare(matchedTicket.id, [tx] as any); + const results = await this.scripEngine.commit(prepared); + + console.log( + `[email] Reply on ticket ${matchedTicket.id}, scrips: ${results.length} (${results.map((r) => r.message).join(', ')})`, + ); + return { action: 'replied', ticketId: matchedTicket.id, detail: `Correspond on ticket ${matchedTicket.id}` }; + } + + // New ticket + const queue = await this.db.query.queues.findFirst({ + where: eq(queues.id, queueId), + }); + + let initialStatus = 'new'; + if (queue?.lifecycle_id) { + const lifecycle = await this.db.query.lifecycles.findFirst({ + where: eq(lifecycles.id, queue.lifecycle_id), + }); + const definition = lifecycle?.definition as LifecycleDefinition | undefined; + initialStatus = definition?.statuses.initial[0] ?? initialStatus; + } + + const [ticket] = await this.db + .insert(tickets) + .values({ + subject: email.subject, + queue_id: queueId, + status: initialStatus, + creator_id: userId, + team_id: (queue as any)?.team_id ?? null, + }) + .returning(); + + if (!ticket) { + return { action: 'skipped', detail: 'Failed to create ticket' }; + } + + // Create transaction + correspond in one batch + const txList = [ + { + ticket_id: ticket.id, + transaction_type: 'Create', + field: 'status', + new_value: initialStatus, + creator_id: userId, + }, + { + ticket_id: ticket.id, + transaction_type: 'Correspond', + field: null, + new_value: null, + data: { + body: email.bodyText || email.bodyHtml || '(no body)', + from: fromAddress, + message_id: email.messageId, + }, + creator_id: userId, + }, + ]; + + await this.db.insert(transactions).values(txList as any); + + // Fire scrips on TransactionBatch (OnCreate + OnCorrespond) + const prepared = await this.scripEngine.prepare(ticket.id, txList as any, 'TransactionBatch'); + const results = await this.scripEngine.commit(prepared); + + console.log( + `[email] Created ticket ${ticket.id} in "${queueName}", scrips: ${results.length} (${results.map((r) => r.message).join(', ')})`, + ); + return { action: 'created', ticketId: ticket.id, detail: `Ticket ${ticket.id} created in ${queueName}` }; + } +} diff --git a/src/email/resolvers.ts b/src/email/resolvers.ts new file mode 100644 index 0000000..c4d3009 --- /dev/null +++ b/src/email/resolvers.ts @@ -0,0 +1,128 @@ +import { eq, ilike, and } from 'drizzle-orm'; +import type { Db } from '../db/index.ts'; +import { users, queues, tickets } from '../db/schema.ts'; + +/** + * Resolve a sender email address to a user record. + * Creates a stub "unverified" user if no match is found. + */ +export async function resolveUser( + db: Db, + fromAddress: string, +): Promise<{ userId: string; isNew: boolean }> { + // Try exact match first + const existing = await db.query.users.findFirst({ + where: eq(users.email, fromAddress), + }); + + if (existing) { + return { userId: existing.id, isNew: false }; + } + + // Create a stub user with role 'unverified' + const username = fromAddress.replace(/@/g, '-at-').replace(/[^a-zA-Z0-9._-]/g, ''); + const [stub] = await db + .insert(users) + .values({ + username, + email: fromAddress, + role: 'unverified', + }) + .returning(); + + if (!stub) { + throw new Error(`Failed to create stub user for ${fromAddress}`); + } + + return { userId: stub.id, isNew: true }; +} + +/** + * Resolve the "to" address to a queue. + * Strategy: + * 1. Check for exact mail_alias match on any queue + * 2. Check if the local-part matches a queue name + * 3. Fallback to the first available queue + */ +export async function resolveQueue( + db: Db, + toAddress: string, +): Promise<{ queueId: string; queueName: string }> { + const localPart = toAddress.split('@')[0]?.toLowerCase() ?? ''; + const fullAddress = toAddress.trim().toLowerCase(); + + // 1. Check mail_alias exact match + if (fullAddress) { + const byAlias = await db.query.queues.findFirst({ + where: eq(queues.mail_alias, fullAddress), + }); + if (byAlias) { + return { queueId: byAlias.id, queueName: byAlias.name }; + } + } + + // 2. Check queue name matching the local-part + if (localPart) { + const byName = await db.query.queues.findFirst({ + where: eq(queues.name, localPart), + }); + if (byName) { + return { queueId: byName.id, queueName: byName.name }; + } + } + + // 3. Fallback to first queue + const allQueues = await db.query.queues.findMany({ limit: 1 }); + const fallback = allQueues[0]; + if (fallback) { + return { queueId: fallback.id, queueName: fallback.name }; + } + + throw new Error('No queues exist — cannot route inbound email'); +} + +/** + * Scan subject for ticket ID patterns. + * Supports: TKT-XXXX, TKT-XXXXX, #NNN, [queue-name #NNN] + * + * Returns the matched ticket if found and not in a closed state, + * or null if no ticket was matched. + */ +export async function matchTicket( + db: Db, + subject: string, +): Promise<{ id: number; subject: string; status: string } | null> { + const trimmed = subject.trim(); + + // TKT-XXXX or TKT-XXXXX (Tessera display format) + const tktMatch = trimmed.match(/\bTKT-(\d{4,5})\b/i); + if (tktMatch) { + const id = parseInt(tktMatch[1]!, 10); + if (!isNaN(id)) { + const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) }); + if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status }; + } + } + + // [queue-name #NNN] (RT-compatible bracket format) + const bracketMatch = trimmed.match(/\[[\w-]+\s*#(\d+)\]/i); + if (bracketMatch) { + const id = parseInt(bracketMatch[1]!, 10); + if (!isNaN(id)) { + const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) }); + if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status }; + } + } + + // #NNN (shorthand, requires word boundary before #) + const hashMatch = trimmed.match(/(?:^|\s)#(\d+)\b/); + if (hashMatch) { + const id = parseInt(hashMatch[1]!, 10); + if (!isNaN(id)) { + const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) }); + if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status }; + } + } + + return null; +} diff --git a/src/email/types.ts b/src/email/types.ts new file mode 100644 index 0000000..1bc139e --- /dev/null +++ b/src/email/types.ts @@ -0,0 +1,24 @@ +export interface InboundEmailAttachment { + filename: string; + mimeType: string; + content: Buffer; +} + +export interface InboundEmail { + from: string; // "Alice " + fromAddress: string; // "alice@example.com" + to: string; // "support@mail.tm" + subject: string; + bodyText: string; + bodyHtml?: string; + attachments: InboundEmailAttachment[]; + messageId: string; // for dedup + receivedAt: Date; +} + +/** Result of processing one inbound email */ +export interface ProcessResult { + action: 'created' | 'replied' | 'skipped'; + ticketId?: number; + detail: string; +} diff --git a/src/index.ts b/src/index.ts index 1286b2b..8c48d4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,10 @@ import { createAuthRouter } from './routes/auth.ts'; import { createQueuePermissionsRouter } from './routes/queue-permissions.ts'; import { createSlaPoliciesRouter } from './routes/sla-policies.ts'; import { createNotificationsRouter } from './routes/notifications.ts'; +import { createMailgateRouter } from './routes/mailgate.ts'; import { startScheduler } from './scrip/scheduler.ts'; +import { EmailProcessor } from './email/processor.ts'; +import { MailTmTransport } from './email/mailtm.ts'; let db: Db | null = null; @@ -39,10 +42,16 @@ app.onError(errorHandler); const { requireAuth, requireAdmin } = createAuthMiddleware(getDb()); +// Email processor (shared between transport and webhook) +const emailProcessor = new EmailProcessor(getDb()); + // Public routes app.route('/health', healthRouter); app.route('/', createAuthRouter(getDb())); +// Mailgate webhook — public endpoint for receiving inbound emails +app.route('/mailgate', createMailgateRouter(getDb(), emailProcessor)); + // Ticket routes — require authentication const ticketsWithAuth = new Hono(); ticketsWithAuth.use('*', requireAuth); @@ -87,4 +96,12 @@ if (Bun.main === import.meta.path) { // Start the scrip scheduler (runs every 5 minutes) startScheduler(getDb()); + + // Start inbound email transport + if (config.MAIL_TRANSPORT === 'mailtm') { + const transport = new MailTmTransport(emailProcessor); + transport.start().catch((err) => { + console.error('[email] Failed to start mail.tm transport:', err instanceof Error ? err.message : String(err)); + }); + } } diff --git a/src/routes/mailgate.ts b/src/routes/mailgate.ts new file mode 100644 index 0000000..8a2ee17 --- /dev/null +++ b/src/routes/mailgate.ts @@ -0,0 +1,69 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { z } from 'zod/v4'; +import type { Db } from '../db/index.ts'; +import type { InboundEmail } from '../email/types.ts'; +import type { EmailProcessor } from '../email/processor.ts'; + +const WebhookPayloadSchema = z.object({ + from: z.string().min(1), + to: z.string().min(1), + subject: z.string().min(1), + body_text: z.string().optional().default(''), + body_html: z.string().optional(), + message_id: z.string().optional(), + attachments: z + .array( + z.object({ + filename: z.string(), + mime_type: z.string().optional().default('application/octet-stream'), + content_base64: z.string(), + }), + ) + .optional() + .default([]), +}); + +/** + * POST /mailgate — webhook endpoint for receiving inbound emails. + * + * Accepts JSON payload from external mail services (SendGrid, Mailgun, etc.). + * When MAIL_TRANSPORT is 'webhook', this is the primary inbound path. + * When MAIL_TRANSPORT is 'mailtm' or 'none', it's still available as a + * secondary path (useful for testing or hybrid setups). + */ +export function createMailgateRouter(db: Db, processor: EmailProcessor): Hono { + const router = new Hono(); + + router.post('/', async (c) => { + const body = await c.req.json(); + const parsed = WebhookPayloadSchema.parse(body); + + const inbound: InboundEmail = { + from: parsed.from, + fromAddress: extractAddress(parsed.from), + to: parsed.to, + subject: parsed.subject, + bodyText: parsed.body_text, + bodyHtml: parsed.body_html, + messageId: parsed.message_id ?? `${Date.now()}-${crypto.randomUUID()}`, + receivedAt: new Date(), + attachments: parsed.attachments.map((att) => ({ + filename: att.filename, + mimeType: att.mime_type, + content: Buffer.from(att.content_base64, 'base64'), + })), + }; + + const result = await processor.process(inbound); + return c.json(result, result.action === 'skipped' ? 200 : 201); + }); + + return router; +} + +function extractAddress(raw: string): string { + const match = raw.match(/<([^>]+)>/); + if (match) return match[1]!.trim().toLowerCase(); + return raw.trim().toLowerCase(); +}