Skip to content

#238 Send an email on expense approval and denial - #326

Open
mehanana wants to merge 2 commits into
mainfrom
238-send-email-on-Expense-Approval-and-denial
Open

#238 Send an email on expense approval and denial#326
mehanana wants to merge 2 commits into
mainfrom
238-send-email-on-Expense-Approval-and-denial

Conversation

@mehanana

Copy link
Copy Markdown
Contributor

ℹ️ Issue

Closes #238

📝 Description

Used SES to send an email when an expense is approved/denied.

Briefly list the changes made to the code:

  1. Added the email functionality to PATCH endpoint where expense gets approved/denied
  2. Created mailer.ts with functions thata support the mail functionality
  3. Added SES email identity, IAM policy, and Env var to infrastructure/aws/lambda.tf to verify & send the email
  4. Wrote tests for the new functionality

✔️ Verification

What steps did you take to verify your changes work? These should be clear enough for someone to be able to clone the branch and follow the steps themselves.

Provide screenshots of any new components, styling changes, or pages.
Screenshot 2026-08-18 231623

🏕️ (Optional) Future Work / Notes

Did you notice anything ugly during the course of this ticket? Any bugs, design challenges, or unexpected behavior? Write it down so we can clean it up in a future ticket!

  • Just wanted to double check if the email address here was correct
  • If the expense is given the status needs_more_info, should this also trigger an email?

wrote tests for the new functionality
github-actions Bot added a commit that referenced this pull request Aug 19, 2026
  - Auto-formatted .tf files with terraform fmt
  - Updated README.md with terraform-docs

  Co-authored-by: mehanana <mehanana@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
data.infisical_secrets.rds_folder: Reading...
data.infisical_secrets.github_folder: Reading...
data.aws_vpc.default: Reading...
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
data.aws_caller_identity.current: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
data.aws_iam_policy_document.ci_migrate_assume: Reading...
data.aws_iam_policy_document.ci_migrate_assume: Read complete after 0s [id=3474878989]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_gateway_response.cors["DEFAULT_5XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_5XX]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_gateway_response.cors["DEFAULT_4XX"]: Refreshing state... [id=aggr-2apxzxb0r8-DEFAULT_4XX]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
data.aws_vpc.default: Read complete after 0s [id=vpc-0a9ccfb59c8918ce9]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_iam_role.ci_migrate: Refreshing state... [id=branch-ci-migrate]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_iam_role_policy.lambda_cognito_admin: Refreshing state... [id=branch-lambda-role:branch-lambda-cognito-admin]
aws_security_group.rds: Refreshing state... [id=sg-0c8a1ff1676a14edb]
aws_api_gateway_resource.lambda_proxy["projects"]: Refreshing state... [id=kmwcxq]
aws_api_gateway_resource.lambda_proxy["reports"]: Refreshing state... [id=elsvn3]
aws_api_gateway_resource.lambda_proxy["users"]: Refreshing state... [id=4sjlu3]
aws_api_gateway_resource.lambda_proxy["donors"]: Refreshing state... [id=xkazax]
aws_api_gateway_resource.lambda_proxy["auth"]: Refreshing state... [id=srhf9j]
aws_api_gateway_resource.lambda_proxy["expenditures"]: Refreshing state... [id=14khv0]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["expenditures-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_method.lambda_methods["auth-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["projects-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["reports-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["donors-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy.lambda_s3_objects: Refreshing state... [id=branch-lambda-role:branch-lambda-s3-objects]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
data.infisical_secrets.rds_folder: Read complete after 1s
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_vpc_security_group_ingress_rule.rds_postgres: Refreshing state... [id=sgr-0594341dc6234d55c]
aws_vpc_security_group_egress_rule.rds_all: Refreshing state... [id=sgr-099fe0d98d4b3f3b5]
aws_api_gateway_method.lambda_proxy_any["projects"]: Refreshing state... [id=agm-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_method.lambda_proxy_any["auth"]: Refreshing state... [id=agm-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_method.lambda_proxy_any["donors"]: Refreshing state... [id=agm-2apxzxb0r8-xkazax-ANY]
aws_api_gateway_method.lambda_proxy_any["reports"]: Refreshing state... [id=agm-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_method.lambda_proxy_any["expenditures"]: Refreshing state... [id=agm-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_method.lambda_proxy_any["users"]: Refreshing state... [id=agm-2apxzxb0r8-4sjlu3-ANY]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
data.infisical_secrets.github_folder: Read complete after 1s
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_iam_role_policy.ci_migrate: Refreshing state... [id=branch-ci-migrate:db-migrate]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_proxy_integrations["donors"]: Refreshing state... [id=agi-2apxzxb0r8-xkazax-ANY]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_proxy_integrations["reports"]: Refreshing state... [id=agi-2apxzxb0r8-elsvn3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["auth"]: Refreshing state... [id=agi-2apxzxb0r8-srhf9j-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["users"]: Refreshing state... [id=agi-2apxzxb0r8-4sjlu3-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["expenditures"]: Refreshing state... [id=agi-2apxzxb0r8-14khv0-ANY]
aws_api_gateway_integration.lambda_proxy_integrations["projects"]: Refreshing state... [id=agi-2apxzxb0r8-kmwcxq-ANY]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-OPTIONS]
aws_api_gateway_integration.lambda_integrations["expenditures-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-PATCH]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-OPTIONS]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-OPTIONS]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["users-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-OPTIONS]
aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-OPTIONS]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-OPTIONS]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=od3a3y]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be updated in-place
  ~ resource "aws_api_gateway_gateway_response" "cors" {
        id                  = "aggr-2apxzxb0r8-DEFAULT_4XX"
      ~ response_templates  = {
          - "application/json" = "{\"message\":$context.error.messageString}" -> null
        }
        # (5 unchanged attributes hidden)
    }

  # aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be updated in-place
  ~ resource "aws_api_gateway_gateway_response" "cors" {
        id                  = "aggr-2apxzxb0r8-DEFAULT_5XX"
      ~ response_templates  = {
          - "application/json" = "{\"message\":$context.error.messageString}" -> null
        }
        # (5 unchanged attributes hidden)
    }

  # aws_iam_role_policy.lambda_ses_send will be created
  + resource "aws_iam_role_policy" "lambda_ses_send" {
      + id          = (known after apply)
      + name        = "branch-lambda-ses-send"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = "branch-lambda-role"
    }

  # aws_lambda_function.functions["auth"] will be updated in-place
  ~ resource "aws_lambda_function" "functions" {
        id                             = "branch-auth"
        tags                           = {}
        # (32 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "SES_FROM_ADDRESS"     = "no-reply@branch.org"
                # (9 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # aws_lambda_function.functions["donors"] will be updated in-place
  ~ resource "aws_lambda_function" "functions" {
        id                             = "branch-donors"
        tags                           = {}
        # (32 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "SES_FROM_ADDRESS"     = "no-reply@branch.org"
                # (9 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # aws_lambda_function.functions["expenditures"] will be updated in-place
  ~ resource "aws_lambda_function" "functions" {
        id                             = "branch-expenditures"
        tags                           = {}
        # (32 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "SES_FROM_ADDRESS"     = "no-reply@branch.org"
                # (9 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # aws_lambda_function.functions["projects"] will be updated in-place
  ~ resource "aws_lambda_function" "functions" {
        id                             = "branch-projects"
        tags                           = {}
        # (32 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "SES_FROM_ADDRESS"     = "no-reply@branch.org"
                # (9 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # aws_lambda_function.functions["reports"] will be updated in-place
  ~ resource "aws_lambda_function" "functions" {
        id                             = "branch-reports"
        tags                           = {}
        # (32 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "SES_FROM_ADDRESS"     = "no-reply@branch.org"
                # (9 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # aws_lambda_function.functions["users"] will be updated in-place
  ~ resource "aws_lambda_function" "functions" {
        id                             = "branch-users"
        tags                           = {}
        # (32 unchanged attributes hidden)

      ~ environment {
          ~ variables = {
              + "SES_FROM_ADDRESS"     = "no-reply@branch.org"
                # (9 unchanged elements hidden)
            }
        }

        # (3 unchanged blocks hidden)
    }

  # aws_ses_email_identity.no_reply will be created
  + resource "aws_ses_email_identity" "no_reply" {
      + arn    = (known after apply)
      + email  = "no-reply@branch.org"
      + id     = (known after apply)
      + region = "us-east-2"
    }

Plan: 2 to add, 8 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Send an email on expense approval and denial

1 participant