thinkwork-cli 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,26 @@ variable "agentcore_memory_id" {
38
38
  default = ""
39
39
  }
40
40
 
41
+ variable "memory_engine" {
42
+ description = "Active long-term memory engine ('hindsight' or 'agentcore'). Surfaced to the runtime as MEMORY_ENGINE for telemetry/debugging only; engine selection itself happens in the API's normalized memory layer when memory-retain is invoked."
43
+ type = string
44
+ default = "hindsight"
45
+ validation {
46
+ condition = contains(["hindsight", "agentcore"], var.memory_engine)
47
+ error_message = "memory_engine must be 'hindsight' or 'agentcore'."
48
+ }
49
+ }
50
+
51
+ # memory-retain Lambda name + ARN are constructed locally rather than
52
+ # taken as inputs to avoid a circular dependency: the lambda-api module
53
+ # already consumes this module's outputs (agentcore_function_name/arn).
54
+ # The Lambda name follows the deterministic pattern from
55
+ # lambda-api/handlers.tf: thinkwork-${stage}-api-${handler_name}.
56
+ locals {
57
+ memory_retain_fn_name = "thinkwork-${var.stage}-api-memory-retain"
58
+ memory_retain_fn_arn = "arn:aws:lambda:${var.region}:${var.account_id}:function:${local.memory_retain_fn_name}"
59
+ }
60
+
41
61
  ################################################################################
42
62
  # ECR Repository
43
63
  ################################################################################
@@ -157,6 +177,17 @@ resource "aws_iam_role_policy" "agentcore" {
157
177
  Action = ["ssm:GetParameter", "ssm:PutParameter"]
158
178
  Resource = "arn:aws:ssm:${var.region}:${var.account_id}:parameter/thinkwork/${var.stage}/agentcore/*"
159
179
  },
180
+ {
181
+ # Async-invoke the memory-retain Lambda after every chat turn so
182
+ # the API's normalized memory layer can run the active engine's
183
+ # retainTurn() path (Hindsight POST /memories or AgentCore
184
+ # CreateEvent). InvocationType=Event from the Python client; this
185
+ # Lambda is the only target.
186
+ Sid = "MemoryRetainInvoke"
187
+ Effect = "Allow"
188
+ Action = ["lambda:InvokeFunction"]
189
+ Resource = local.memory_retain_fn_arn
190
+ },
160
191
  ]
161
192
  })
162
193
  }
@@ -192,6 +223,8 @@ resource "aws_lambda_function" "agentcore" {
192
223
  AWS_LWA_PORT = "8080"
193
224
  AGENTCORE_MEMORY_ID = var.agentcore_memory_id
194
225
  AGENTCORE_FILES_BUCKET = var.bucket_name
226
+ MEMORY_ENGINE = var.memory_engine
227
+ MEMORY_RETAIN_FN_NAME = local.memory_retain_fn_name
195
228
  }
196
229
  }
197
230
 
@@ -55,6 +55,27 @@ resource "aws_iam_role" "job_scheduler" {
55
55
  })
56
56
  }
57
57
 
58
+ # When job-schedule-manager creates a schedule, it sets this role as the
59
+ # target RoleArn. EventBridge Scheduler assumes it and invokes the
60
+ # job-trigger Lambda. ARN is constructed by naming convention so this
61
+ # module doesn't have to import the lambda-api module's outputs.
62
+ resource "aws_iam_role_policy" "job_scheduler_invoke" {
63
+ name = "invoke-job-trigger"
64
+ role = aws_iam_role.job_scheduler.id
65
+
66
+ policy = jsonencode({
67
+ Version = "2012-10-17"
68
+ Statement = [{
69
+ Effect = "Allow"
70
+ Action = ["lambda:InvokeFunction"]
71
+ Resource = [
72
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-trigger",
73
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-trigger:*",
74
+ ]
75
+ }]
76
+ })
77
+ }
78
+
58
79
  ################################################################################
59
80
  # Outputs
60
81
  ################################################################################
@@ -28,17 +28,40 @@ locals {
28
28
  GRAPHQL_API_KEY = var.appsync_api_key
29
29
  API_AUTH_SECRET = var.api_auth_secret
30
30
  THINKWORK_API_SECRET = var.api_auth_secret
31
- MANIFLOW_API_SECRET = var.api_auth_secret
31
+ EMAIL_HMAC_SECRET = var.api_auth_secret
32
+ THINKWORK_API_URL = "https://${aws_apigatewayv2_api.main.id}.execute-api.${var.region}.amazonaws.com"
32
33
  AGENTCORE_FUNCTION_NAME = var.agentcore_function_name
33
34
  WORKSPACE_BUCKET = var.bucket_name
34
35
  HINDSIGHT_ENDPOINT = var.hindsight_endpoint
35
36
  AGENTCORE_MEMORY_ID = var.agentcore_memory_id
36
- ADMIN_URL = var.admin_url
37
- DOCS_URL = var.docs_url
38
- APPSYNC_REALTIME_URL = var.appsync_realtime_url
39
- ECR_REPOSITORY_URL = var.ecr_repository_url
40
- AWS_ACCOUNT_ID = var.account_id
41
- NODE_OPTIONS = "--enable-source-maps"
37
+ MEMORY_ENGINE = var.memory_engine
38
+ # Skip the SSM indirection for cross-function ARN lookup. Terraform
39
+ # already knows this ARN at apply time and the Lambda role's SSM
40
+ # permission has been a recurring source of silent failures where
41
+ # getChatAgentInvokeFnArn falls back to null and sendMessage loses
42
+ # message_history on the wakeup-processor fallback path.
43
+ CHAT_AGENT_INVOKE_FN_ARN = "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-chat-agent-invoke"
44
+ ADMIN_URL = var.admin_url
45
+ DOCS_URL = var.docs_url
46
+ APPSYNC_REALTIME_URL = var.appsync_realtime_url
47
+ ECR_REPOSITORY_URL = var.ecr_repository_url
48
+ AWS_ACCOUNT_ID = var.account_id
49
+ NODE_OPTIONS = "--enable-source-maps"
50
+ # LastMile Tasks REST API base URL — feature-flags the outbound sync
51
+ # path. When unset, syncExternalTaskOnCreate writes sync_status='local'
52
+ # and the workflow picker proxy returns 503. Set to the LMI develop /
53
+ # staging / prod base URL per stage to enable real cross-system sync.
54
+ LASTMILE_TASKS_API_URL = var.lastmile_tasks_api_url
55
+ }
56
+
57
+ # Per-handler env-var overrides. ARNs are constructed from the naming
58
+ # pattern (same trick as lambda_api_cross_invoke in main.tf) so we don't
59
+ # introduce a self-referential dependency inside the handler for_each.
60
+ handler_extra_env = {
61
+ "job-schedule-manager" = {
62
+ JOB_TRIGGER_ARN = "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-trigger"
63
+ JOB_TRIGGER_ROLE_ARN = var.job_scheduler_role_arn
64
+ }
42
65
  }
43
66
  }
44
67
 
@@ -69,8 +92,11 @@ resource "aws_lambda_function" "handler" {
69
92
  "guardrails",
70
93
  "scheduled-jobs",
71
94
  "job-schedule-manager",
95
+ "job-trigger",
72
96
  "webhooks",
73
97
  "webhooks-admin",
98
+ "webhook-deliveries-cleanup",
99
+ "task-connectors",
74
100
  "workspace-files",
75
101
  "knowledge-base-manager",
76
102
  "knowledge-base-files",
@@ -79,10 +105,9 @@ resource "aws_lambda_function" "handler" {
79
105
  "github-app",
80
106
  "github-repos",
81
107
  "memory",
108
+ "memory-retain",
82
109
  "artifact-deliver",
83
110
  "recipe-refresh",
84
- "connector-installs",
85
- "connector-secrets",
86
111
  "agent-skills-list",
87
112
  "bootstrap-workspaces",
88
113
  "code-factory",
@@ -99,9 +124,11 @@ resource "aws_lambda_function" "handler" {
99
124
  source_code_hash = filebase64sha256("${var.lambda_zips_dir}/${each.key}.zip")
100
125
 
101
126
  environment {
102
- variables = merge(local.common_env, {
103
- FUNCTION_NAME = each.key
104
- })
127
+ variables = merge(
128
+ local.common_env,
129
+ { FUNCTION_NAME = each.key },
130
+ lookup(local.handler_extra_env, each.key, {}),
131
+ )
105
132
  }
106
133
 
107
134
  tags = {
@@ -195,6 +222,10 @@ locals {
195
222
  "ANY /api/webhooks/{proxy+}" = "webhooks-admin"
196
223
  "ANY /api/webhooks" = "webhooks-admin"
197
224
 
225
+ # Task Connectors admin
226
+ "ANY /api/task-connectors/{proxy+}" = "task-connectors"
227
+ "ANY /api/task-connectors" = "task-connectors"
228
+
198
229
  # Workspace files
199
230
  "ANY /api/workspaces/{proxy+}" = "workspace-files"
200
231
 
@@ -216,10 +247,6 @@ locals {
216
247
  # GitHub App
217
248
  "ANY /api/github-app/{proxy+}" = "github-app"
218
249
  "POST /api/github/webhook" = "github-app"
219
-
220
- # Connectors
221
- "ANY /api/connector-installs/{proxy+}" = "connector-installs"
222
- "ANY /api/connector-secrets/{proxy+}" = "connector-secrets"
223
250
  } : {}
224
251
  }
225
252
 
@@ -272,6 +299,28 @@ resource "aws_scheduler_schedule" "wakeup_processor" {
272
299
  }
273
300
  }
274
301
 
302
+ # ---------------------------------------------------------------------------
303
+ # webhook_deliveries retention cron — daily delete of rows older than 90 days
304
+ # ---------------------------------------------------------------------------
305
+
306
+ resource "aws_scheduler_schedule" "webhook_deliveries_cleanup" {
307
+ count = local.use_local_zips ? 1 : 0
308
+
309
+ name = "thinkwork-${var.stage}-webhook-deliveries-cleanup"
310
+ group_name = "default"
311
+ schedule_expression = "cron(0 4 * * ? *)" # daily at 04:00 UTC
312
+ state = "ENABLED"
313
+
314
+ flexible_time_window {
315
+ mode = "OFF"
316
+ }
317
+
318
+ target {
319
+ arn = aws_lambda_function.handler["webhook-deliveries-cleanup"].arn
320
+ role_arn = aws_iam_role.scheduler.arn
321
+ }
322
+ }
323
+
275
324
  resource "aws_iam_role" "scheduler" {
276
325
  name = "thinkwork-${var.stage}-scheduler-role"
277
326
 
@@ -308,6 +357,7 @@ resource "aws_ssm_parameter" "lambda_arns" {
308
357
  "chat-agent-invoke-fn-arn" = aws_lambda_function.handler["chat-agent-invoke"].arn
309
358
  "kb-manager-fn-arn" = aws_lambda_function.handler["knowledge-base-manager"].arn
310
359
  "job-schedule-manager-fn-arn" = aws_lambda_function.handler["job-schedule-manager"].arn
360
+ "memory-retain-fn-arn" = aws_lambda_function.handler["memory-retain"].arn
311
361
  } : {}
312
362
 
313
363
  name = "/thinkwork/${var.stage}/${each.key}"
@@ -28,7 +28,7 @@ resource "aws_apigatewayv2_api" "main" {
28
28
 
29
29
  cors_configuration {
30
30
  allow_headers = ["Content-Type", "Authorization", "x-api-key", "x-tenant-id", "x-tenant-slug", "x-principal-id"]
31
- allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
31
+ allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
32
32
  allow_origins = var.cors_allowed_origins
33
33
  max_age = 3600
34
34
  }
@@ -206,6 +206,30 @@ resource "aws_iam_role_policy" "lambda_bedrock" {
206
206
  })
207
207
  }
208
208
 
209
+ # SES send permissions for the email-send handler. Scoped to any
210
+ # verified identity in this account+region so the email-send Lambda
211
+ # can SendRawEmail from agents.thinkwork.ai (and any other domain
212
+ # identity a future deployment might add).
213
+ resource "aws_iam_role_policy" "lambda_ses_send" {
214
+ name = "ses-send"
215
+ role = aws_iam_role.lambda.id
216
+
217
+ policy = jsonencode({
218
+ Version = "2012-10-17"
219
+ Statement = [{
220
+ Effect = "Allow"
221
+ Action = [
222
+ "ses:SendEmail",
223
+ "ses:SendRawEmail",
224
+ ]
225
+ Resource = [
226
+ "arn:aws:ses:${var.region}:${var.account_id}:identity/*",
227
+ "arn:aws:ses:${var.region}:${var.account_id}:configuration-set/*",
228
+ ]
229
+ }]
230
+ })
231
+ }
232
+
209
233
  # Allow API Lambdas to directly invoke the AgentCore Lambda. Used by
210
234
  # chat-agent-invoke (and future wake-up/retry paths) via InvokeCommand.
211
235
  resource "aws_iam_role_policy" "lambda_agentcore_invoke" {
@@ -232,7 +256,7 @@ resource "aws_iam_role_policy" "lambda_agentcore_invoke" {
232
256
  # memoryRecords / memorySearch call ListMemoryRecordsCommand to fetch
233
257
  # records across the tenant's agents.
234
258
  resource "aws_iam_role_policy" "lambda_agentcore_memory" {
235
- name = "agentcore-memory-read"
259
+ name = "agentcore-memory-rw"
236
260
  role = aws_iam_role.lambda.id
237
261
 
238
262
  policy = jsonencode({
@@ -243,12 +267,106 @@ resource "aws_iam_role_policy" "lambda_agentcore_memory" {
243
267
  "bedrock-agentcore:ListMemoryRecords",
244
268
  "bedrock-agentcore:RetrieveMemoryRecords",
245
269
  "bedrock-agentcore:GetMemoryRecord",
270
+ "bedrock-agentcore:BatchCreateMemoryRecords",
271
+ "bedrock-agentcore:BatchUpdateMemoryRecords",
272
+ "bedrock-agentcore:BatchDeleteMemoryRecords",
273
+ "bedrock-agentcore:DeleteMemoryRecord",
246
274
  ]
247
275
  Resource = "*"
248
276
  }]
249
277
  })
250
278
  }
251
279
 
280
+ # graphql-http's sendMessage mutation reads SSM parameters like
281
+ # /thinkwork/${stage}/chat-agent-invoke-fn-arn to discover the direct
282
+ # Lambda targets for cross-function invocation. Without this, the SSM
283
+ # GetParameter call fails with AccessDenied, the caller silently
284
+ # catches the error, and sendMessage falls back to the wakeup-processor
285
+ # path — which doesn't load messages_history from Aurora. That's why
286
+ # multi-turn chat was losing prior context: history was only loaded on
287
+ # the direct path, which never ran.
288
+ resource "aws_iam_role_policy" "lambda_ssm_read" {
289
+ name = "ssm-param-read"
290
+ role = aws_iam_role.lambda.id
291
+
292
+ policy = jsonencode({
293
+ Version = "2012-10-17"
294
+ Statement = [{
295
+ Effect = "Allow"
296
+ Action = [
297
+ "ssm:GetParameter",
298
+ "ssm:GetParameters",
299
+ ]
300
+ Resource = "arn:aws:ssm:${var.region}:${var.account_id}:parameter/thinkwork/${var.stage}/*"
301
+ }]
302
+ })
303
+ }
304
+
305
+ # job-schedule-manager creates/updates/deletes EventBridge Scheduler
306
+ # schedules (and the thinkwork-jobs schedule group on first use). Without
307
+ # these permissions the manager Lambda threw silently and every scheduled
308
+ # automation was orphaned with eb_schedule_name = null.
309
+ resource "aws_iam_role_policy" "lambda_scheduler" {
310
+ name = "eventbridge-scheduler-rw"
311
+ role = aws_iam_role.lambda.id
312
+
313
+ policy = jsonencode({
314
+ Version = "2012-10-17"
315
+ Statement = [
316
+ {
317
+ Effect = "Allow"
318
+ Action = [
319
+ "scheduler:CreateSchedule",
320
+ "scheduler:UpdateSchedule",
321
+ "scheduler:DeleteSchedule",
322
+ "scheduler:GetSchedule",
323
+ "scheduler:ListSchedules",
324
+ "scheduler:CreateScheduleGroup",
325
+ "scheduler:GetScheduleGroup",
326
+ "scheduler:DeleteScheduleGroup",
327
+ "scheduler:TagResource",
328
+ ]
329
+ Resource = "*"
330
+ },
331
+ # Scheduler.CreateSchedule takes a RoleArn for the target; AWS requires
332
+ # the caller to have iam:PassRole on that role. Without this the
333
+ # CreateSchedule call fails with AccessDenied even if the scheduler
334
+ # permissions above are set.
335
+ {
336
+ Effect = "Allow"
337
+ Action = ["iam:PassRole"]
338
+ Resource = var.job_scheduler_role_arn != "" ? var.job_scheduler_role_arn : "*"
339
+ },
340
+ ]
341
+ })
342
+ }
343
+
344
+ # Allow API handler Lambdas to invoke each other directly. sendMessage
345
+ # dispatches to chat-agent-invoke for instant chat response; the memory
346
+ # resolvers reach knowledge-base-manager and job-schedule-manager for
347
+ # admin-driven operations. The existing lambda_agentcore_invoke policy
348
+ # covers the Strands runtime Lambda only — this one covers internal
349
+ # api-to-api calls. ARNs are constructed deterministically from the
350
+ # handler naming pattern so we don't create a dependency cycle with the
351
+ # handler resource.
352
+ resource "aws_iam_role_policy" "lambda_api_cross_invoke" {
353
+ name = "api-cross-function-invoke"
354
+ role = aws_iam_role.lambda.id
355
+
356
+ policy = jsonencode({
357
+ Version = "2012-10-17"
358
+ Statement = [{
359
+ Effect = "Allow"
360
+ Action = ["lambda:InvokeFunction"]
361
+ Resource = [
362
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-chat-agent-invoke",
363
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-knowledge-base-manager",
364
+ "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-job-schedule-manager",
365
+ ]
366
+ }]
367
+ })
368
+ }
369
+
252
370
  ################################################################################
253
371
  # Placeholder Lambda — proves the infrastructure works
254
372
  #
@@ -22,3 +22,23 @@ output "lambda_role_name" {
22
22
  description = "Shared Lambda execution role name"
23
23
  value = aws_iam_role.lambda.name
24
24
  }
25
+
26
+ output "memory_retain_fn_name" {
27
+ description = "Memory-retain Lambda function name. Strands runtime invokes this directly to push conversational turns into the active memory engine."
28
+ value = local.use_local_zips ? aws_lambda_function.handler["memory-retain"].function_name : ""
29
+ }
30
+
31
+ output "memory_retain_fn_arn" {
32
+ description = "Memory-retain Lambda ARN. Used to grant lambda:InvokeFunction to the agentcore-runtime role."
33
+ value = local.use_local_zips ? aws_lambda_function.handler["memory-retain"].arn : ""
34
+ }
35
+
36
+ output "email_inbound_fn_arn" {
37
+ description = "email-inbound Lambda ARN. Used by the SES module to wire the receipt rule Lambda action."
38
+ value = local.use_local_zips ? aws_lambda_function.handler["email-inbound"].arn : ""
39
+ }
40
+
41
+ output "email_inbound_fn_name" {
42
+ description = "email-inbound Lambda function name. Used by the SES module for lambda:InvokeFunction permissions."
43
+ value = local.use_local_zips ? aws_lambda_function.handler["email-inbound"].function_name : ""
44
+ }
@@ -146,6 +146,16 @@ variable "agentcore_memory_id" {
146
146
  default = ""
147
147
  }
148
148
 
149
+ variable "memory_engine" {
150
+ description = "Active long-term memory engine for this deployment. Exactly one engine is canonical for recall/inspect/export. Defaults to 'hindsight' for hosted ThinkWork; self-hosted/serverless deployments may choose 'agentcore'."
151
+ type = string
152
+ default = "hindsight"
153
+ validation {
154
+ condition = contains(["hindsight", "agentcore"], var.memory_engine)
155
+ error_message = "memory_engine must be 'hindsight' or 'agentcore'."
156
+ }
157
+ }
158
+
149
159
  variable "agentcore_function_name" {
150
160
  description = "AgentCore Lambda function name (for direct SDK invoke)"
151
161
  type = string
@@ -187,3 +197,15 @@ variable "cors_allowed_origins" {
187
197
  type = list(string)
188
198
  default = ["*"]
189
199
  }
200
+
201
+ variable "job_scheduler_role_arn" {
202
+ description = "IAM role ARN that EventBridge Scheduler assumes to invoke the job-trigger Lambda. Passed from the job-triggers module."
203
+ type = string
204
+ default = ""
205
+ }
206
+
207
+ variable "lastmile_tasks_api_url" {
208
+ description = "Base URL of the LastMile Tasks REST API used by the outbound sync path (POST /tasks, GET /workflows, etc). Leave blank to feature-flag the integration off; mobile-created tasks then land in sync_status='local' until the URL is set."
209
+ type = string
210
+ default = ""
211
+ }
@@ -1,9 +1,17 @@
1
1
  ################################################################################
2
2
  # SES Email — App Module
3
3
  #
4
- # Configures SES for email inbound (receipt rules) and domain verification.
5
- # Full Lambda wiring comes in Phase 4 when email handlers are migrated.
6
- # Phase 1 creates the domain identity and DKIM records only.
4
+ # Wires up inbound and outbound email for a delegated subdomain
5
+ # (e.g. agents.thinkwork.ai). The module:
6
+ #
7
+ # 1. Creates a Route53 hosted zone for the subdomain (Option A — delegated
8
+ # subzone). The operator pastes the output name servers at whatever hosts
9
+ # the parent domain (Google, Squarespace, Cloudflare, etc.).
10
+ # 2. Creates the SES domain identity + DKIM tokens.
11
+ # 3. Writes the SES verification TXT, DKIM CNAMEs, and an MX record into the
12
+ # new subzone.
13
+ # 4. Creates an SES receipt rule set that stores inbound mail in S3 at
14
+ # `email/inbound/<sesMessageId>` and invokes the email-inbound Lambda.
7
15
  ################################################################################
8
16
 
9
17
  variable "stage" {
@@ -16,36 +24,191 @@ variable "account_id" {
16
24
  type = string
17
25
  }
18
26
 
27
+ variable "region" {
28
+ description = "AWS region (determines the inbound SMTP endpoint)"
29
+ type = string
30
+ default = "us-east-1"
31
+ }
32
+
19
33
  variable "email_domain" {
20
- description = "Domain for SES email (e.g. thinkwork.ai)"
34
+ description = "Subdomain used for agent email (e.g. agents.thinkwork.ai). Leave empty to skip all SES resources."
35
+ type = string
36
+ default = ""
37
+ }
38
+
39
+ variable "inbound_bucket_name" {
40
+ description = "S3 bucket that SES writes raw inbound .eml files into. Its policy must already allow ses.amazonaws.com PutObject."
21
41
  type = string
22
42
  default = ""
23
43
  }
24
44
 
45
+ variable "email_inbound_fn_arn" {
46
+ description = "ARN of the email-inbound Lambda. If empty, receipt rule is still created but without a Lambda action."
47
+ type = string
48
+ default = ""
49
+ }
50
+
51
+ variable "email_inbound_fn_name" {
52
+ description = "Function name of the email-inbound Lambda (for the Lambda permission)."
53
+ type = string
54
+ default = ""
55
+ }
56
+
57
+ variable "manage_active_rule_set" {
58
+ description = "Activate the receipt rule set. Only ONE rule set can be active per region per account, so set false in secondary stages that share an account."
59
+ type = bool
60
+ default = true
61
+ }
62
+
63
+ locals {
64
+ enabled = var.email_domain != ""
65
+ inbound_smtp = "inbound-smtp.${var.region}.amazonaws.com"
66
+ rule_set_name = "thinkwork-${var.stage}-email-rules"
67
+ has_lambda = var.email_inbound_fn_arn != ""
68
+ has_bucket = var.inbound_bucket_name != ""
69
+ }
70
+
71
+ ################################################################################
72
+ # Route53 — delegated subzone for the agent email subdomain
73
+ ################################################################################
74
+
75
+ resource "aws_route53_zone" "agents" {
76
+ count = local.enabled ? 1 : 0
77
+ name = var.email_domain
78
+
79
+ tags = {
80
+ Name = "thinkwork-${var.stage}-email-zone"
81
+ Stage = var.stage
82
+ }
83
+ }
84
+
25
85
  ################################################################################
26
- # SES Domain Identity (only if email_domain is provided)
86
+ # SES Domain Identity + DKIM
27
87
  ################################################################################
28
88
 
29
89
  resource "aws_ses_domain_identity" "main" {
30
- count = var.email_domain != "" ? 1 : 0
90
+ count = local.enabled ? 1 : 0
31
91
  domain = var.email_domain
32
92
  }
33
93
 
34
94
  resource "aws_ses_domain_dkim" "main" {
35
- count = var.email_domain != "" ? 1 : 0
95
+ count = local.enabled ? 1 : 0
36
96
  domain = aws_ses_domain_identity.main[0].domain
37
97
  }
38
98
 
99
+ ################################################################################
100
+ # DNS records in the subzone — verification TXT, DKIM CNAMEs, MX
101
+ ################################################################################
102
+
103
+ resource "aws_route53_record" "ses_verification" {
104
+ count = local.enabled ? 1 : 0
105
+ zone_id = aws_route53_zone.agents[0].zone_id
106
+ name = "_amazonses.${var.email_domain}"
107
+ type = "TXT"
108
+ ttl = 600
109
+ records = [aws_ses_domain_identity.main[0].verification_token]
110
+ }
111
+
112
+ resource "aws_route53_record" "dkim" {
113
+ count = local.enabled ? 3 : 0
114
+ zone_id = aws_route53_zone.agents[0].zone_id
115
+ name = "${aws_ses_domain_dkim.main[0].dkim_tokens[count.index]}._domainkey.${var.email_domain}"
116
+ type = "CNAME"
117
+ ttl = 600
118
+ records = ["${aws_ses_domain_dkim.main[0].dkim_tokens[count.index]}.dkim.amazonses.com"]
119
+ }
120
+
121
+ resource "aws_route53_record" "mx" {
122
+ count = local.enabled ? 1 : 0
123
+ zone_id = aws_route53_zone.agents[0].zone_id
124
+ name = var.email_domain
125
+ type = "MX"
126
+ ttl = 600
127
+ records = ["10 ${local.inbound_smtp}"]
128
+ }
129
+
130
+ ################################################################################
131
+ # SES Receipt Rule Set + Rule → S3 + Lambda
132
+ ################################################################################
133
+
134
+ resource "aws_ses_receipt_rule_set" "main" {
135
+ count = local.enabled ? 1 : 0
136
+ rule_set_name = local.rule_set_name
137
+ }
138
+
139
+ resource "aws_ses_active_receipt_rule_set" "main" {
140
+ count = local.enabled && var.manage_active_rule_set ? 1 : 0
141
+ rule_set_name = aws_ses_receipt_rule_set.main[0].rule_set_name
142
+ }
143
+
144
+ resource "aws_lambda_permission" "ses_invoke_email_inbound" {
145
+ count = local.enabled && local.has_lambda ? 1 : 0
146
+ statement_id = "AllowSESInvokeEmailInbound"
147
+ action = "lambda:InvokeFunction"
148
+ function_name = var.email_inbound_fn_name
149
+ principal = "ses.amazonaws.com"
150
+ source_account = var.account_id
151
+ }
152
+
153
+ resource "aws_ses_receipt_rule" "inbound" {
154
+ count = local.enabled ? 1 : 0
155
+ name = "thinkwork-${var.stage}-inbound-email"
156
+ rule_set_name = aws_ses_receipt_rule_set.main[0].rule_set_name
157
+ recipients = [var.email_domain]
158
+ enabled = true
159
+ scan_enabled = true
160
+
161
+ dynamic "s3_action" {
162
+ for_each = local.has_bucket ? [1] : []
163
+ content {
164
+ bucket_name = var.inbound_bucket_name
165
+ object_key_prefix = "email/inbound/"
166
+ position = 1
167
+ }
168
+ }
169
+
170
+ dynamic "lambda_action" {
171
+ for_each = local.has_lambda ? [1] : []
172
+ content {
173
+ function_arn = var.email_inbound_fn_arn
174
+ invocation_type = "Event"
175
+ position = local.has_bucket ? 2 : 1
176
+ }
177
+ }
178
+
179
+ depends_on = [aws_lambda_permission.ses_invoke_email_inbound]
180
+ }
181
+
39
182
  ################################################################################
40
183
  # Outputs
41
184
  ################################################################################
42
185
 
43
186
  output "ses_domain_identity_arn" {
44
187
  description = "SES domain identity ARN"
45
- value = var.email_domain != "" ? aws_ses_domain_identity.main[0].arn : null
188
+ value = local.enabled ? aws_ses_domain_identity.main[0].arn : null
46
189
  }
47
190
 
48
191
  output "dkim_tokens" {
49
- description = "DKIM tokens for DNS verification"
50
- value = var.email_domain != "" ? aws_ses_domain_dkim.main[0].dkim_tokens : []
192
+ description = "DKIM tokens (already written as CNAMEs in the subzone)"
193
+ value = local.enabled ? aws_ses_domain_dkim.main[0].dkim_tokens : []
194
+ }
195
+
196
+ output "zone_id" {
197
+ description = "Route53 hosted zone ID for the email subdomain"
198
+ value = local.enabled ? aws_route53_zone.agents[0].zone_id : null
199
+ }
200
+
201
+ output "name_servers" {
202
+ description = "Name servers for the delegated email subzone. Paste these as NS records at the registrar that hosts the parent domain (e.g. Google Domains) before SES can verify."
203
+ value = local.enabled ? aws_route53_zone.agents[0].name_servers : []
204
+ }
205
+
206
+ output "mx_target" {
207
+ description = "MX target host for the email subdomain"
208
+ value = local.enabled ? local.inbound_smtp : null
209
+ }
210
+
211
+ output "rule_set_name" {
212
+ description = "SES receipt rule set name"
213
+ value = local.enabled ? aws_ses_receipt_rule_set.main[0].rule_set_name : null
51
214
  }