Every Tuesday and Thursday at 9am, a little email slides into my inbox ๐Ÿ“ฌ. It tells me what the stocks I own (or am eyeing ๐Ÿ‘€) opened at, how far they've moved in the last month, and what the rand is doing against the dollar.

It solves no big problem. Nobody asked for it. I just wanted to keep an eye on the market without opening five apps before my coffee, so I built it.

This is the story of that email: how it started as one Lambda doing everything, how it turned into four small pieces with one job each, and what I'd write differently now that I've grown a bit as an engineer.

V1: one Lambda to rule them all ๐Ÿง™โ€โ™‚๏ธ

The first version was a single C# (.NET 8) Lambda, legacy-market-data_lambda. It was a one-function band, and on every run it would:

  1. Ask Polygon.io for each ticker's opening price today and one month ago, nudging weekend dates back to Friday ๐Ÿ“….
  2. Grab the USD/ZAR rate for the previous market day.
  3. Build the email's HTML by gluing strings together in code ๐Ÿงต.
  4. Fire it off through Amazon SES to recipients hardcoded right there in the function.

And it worked! The free tier only allows 5 requests a minute, so after every 4 calls it would nap for 60 seconds ๐Ÿ˜ด. Slow, but reliable. The catch: everything lived in one place, so changing a ticker, adding a subscriber or moving a pixel in the email meant redeploying the whole thing.

So why rebuild something that works? ๐Ÿค”

Honest answer: to learn. The bank I work at runs on Java and Spring, and I wanted more reps with both outside of work hours ๐Ÿ‹๏ธ. Rewriting a C# service that already worked was the perfect excuse.

Once I started, porting one big function felt wrong, so I pulled it apart:

  • Fetching data is a scheduled job that calls an API and does some maths. Python is quick to write and perfect for that.
  • Sending email is the part I wanted in Java, as a Spring Boot service.
  • Config like tickers and subscribers moved out of code and into DynamoDB, so changing them no longer means a redeploy.

The Email API is also meant to grow. It's my personal email service: whenever I build and host another side project that needs to send something, it gets its own endpoint and template here ๐Ÿ“ฎ. Anything that isn't personal (work, a client, someone else's product) gets its own separate service. This one is just for me.

V2: four pieces, one job each ๐Ÿงฉ

In V2 every part does one thing, and the Email API has no idea where its data comes from. It just gets handed numbers and makes them pretty ๐Ÿ’….

EventBridge triggers the market-data Lambda, which calls Polygon.io and reads tickers and subscribers from DynamoDB, then POSTs to API Gateway. An authoriser Lambda checks the API key, the gateway forwards to the Email API, and the Email API sends through Amazon SES.

โฐ EventBridge wakes up the market-data Lambda at 07:00 UTC (09:00 here in South Africa) on Tuesdays and Thursdays. It reads the tickers and subscribers from a DynamoDB config table, then asks Polygon for yesterday's opening price and the price about 30 days earlier for each ticker. It still sleeps every 4 calls ๐Ÿ˜ด, works out the percentage change, adds the USD/ZAR rate and POSTs the lot to the Email API with an x-api-key header.

๐Ÿ›‚ That request hits API Gateway first, where a tiny Python authoriser checks the key against its own DynamoDB table. No key in the table, no entry. Once it's through, the Email API renders the email and hands it to SES to deliver ๐Ÿš€.

Inside the Email API ๐Ÿ”ง

The Email API is a small Spring Boot 4 app on Java 25. Right now it has exactly one endpoint, POST /api/v1/send-market-update, which takes the stocks, the forex rate and who to send it to:

{
  "stocks": [
    { "symbol": "UBER", "open": "75.40", "pastOpen": "70.20", "percentageDifference": "7.41" }
  ],
  "forex": { "ticker": "USDZAR", "openingPrice": 18.45 },
  "recipients": ["me@example.com"]
}

Making it pretty with Thymeleaf ๐ŸŽจ

V1 glued HTML strings together in C#. V2 keeps the layout in a Thymeleaf template, market-report.html. Every stock becomes a little card with its current open, its open a month ago and the % change, plus a green-edged card for USD/ZAR ๐Ÿ’š. The service drops the data into a Thymeleaf Context, renders the template and passes the HTML to SES:

Context context = new Context();
context.setVariables(request.variables());

String htmlBody = templateEngine.process(request.templateName(), context);

SendEmailRequest sendEmailRequest = SendEmailRequest.builder()
        .fromEmailAddress("updates@lvmolemi.com")
        .destination(d -> d.toAddresses(request.to()))
        .content(c -> c.simple(m -> m
                .subject(s -> s.data(request.subject()).charset("UTF-8"))
                .body(b -> b.html(h -> h.data(htmlBody).charset("UTF-8")))
        ))
        .build();

sesClient.sendEmail(sendEmailRequest);

Each recipient gets their own email, and if one fails it's logged and the rest still go out. Want to change the layout? Edit an HTML file, not Java ๐Ÿ™Œ.

A whole Spring Boot appโ€ฆon Lambda? ๐Ÿคฏ

Yep. I wanted a normal Spring Boot app, not a rewrite into a Lambda handler. The AWS Lambda Web Adapter makes that work: it's copied into the container as a Lambda extension, starts the app on port 8080 and translates Lambda events into plain HTTP requests ๐Ÿช„.

FROM amazoncorretto:25-al2023
WORKDIR /app

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.0-rc1 /lambda-adapter /opt/extensions/lambda-adapter
COPY target/email-api-*.jar app.jar

ENV PORT=8080
ENTRYPOINT ["java", "-Xmx512m", "-jar", "app.jar"]

The one setting that matters is AWS_LWA_REMOVE_BASE_PATH=/prod. API Gateway sticks the stage name on the front of the path, and the adapter peels it off so Spring still sees /api/v1/send-market-update. The function gets 2 GB of memory and a 50-second timeout. Full disclosure: those numbers were pulled out of thin air ๐ŸŽฒ. Cold starts don't really matter for something that runs twice a week, and the emails have shown up every single time โœ….

A GitHub Actions workflow builds the image and pushes it to Amazon ECR when I trigger it by hand from the develop branch.

Then vs now: how my Java has changed ๐ŸŒฑ

Since writing the Email API, I built customer-statements_springboot, the project I put together for my software engineer promotion at Capitec. Putting the two side by side is a bit like reading your old school essays ๐Ÿ˜…. Nothing in the Email API is broken, but here's what past-me did that present-me would do differently.

1. Records instead of inheritance ๐Ÿงฌ

The market update request extends a BaseEmailRequest class, held together with Lombok getters, setters and hand-written constructors, just so every request shares a recipients list. Meanwhile the stocks and forex payloads are already records. In customer-statements I lean on records for responses and config, nesting them where it helps. Today I'd write:

public record MarketUpdateRequest(
        @NotEmpty List<@Valid StockData> stocks,
        @NotNull @Valid ForexData forex,
        @NotEmpty List<@Email String> recipients) { }

One immutable type, no inheritance, and the shape of the request is readable at a glance ๐Ÿ‘€.

2. Actually validating input ๐Ÿ›ก๏ธ

The Email API takes @RequestBody MarketUpdateRequest and trusts it completely. An empty recipient list or a missing forex block sails straight through to Thymeleaf ๐Ÿซ . In customer-statements, requests are @Valid with Jakarta constraints, and I even wrote a custom @ValidStatementFile validator that checks the content type, the PDF magic bytes and a 10 MB size limit. Validation failures come back as a tidy 400 listing what was wrong. The Email API deserves the same, starting with that @Valid above.

3. Config in config, not in code โš™๏ธ

The Email API hardcodes its sender address, the SES region (Region.AF_SOUTH_1), the subject and the email title (which, fun fact, says "Weekly Market Insights" on an email that goes out twice a week ๐Ÿ™ƒ). Its application.yaml holds just the app name. In customer-statements everything tuneable lives in typed @ConfigurationProperties records like ApplicationConfigurationProperties and UserServiceProperties. For the Email API that would look like:

@ConfigurationProperties("email")
public record EmailProperties(String fromAddress, String region, String marketUpdateSubject) { }

That also matters for where this API is headed: every new endpoint brings its own template and subject, and those belong in config, not scattered through the service ๐Ÿ“ฎ.

4. Errors that tell the truth ๐Ÿšจ

Three things I'd fix in the Email API's error handling:

  • The service catches a failure per recipient, logs it and carries on, then returns 200 OK no matter what. If every email failed, the caller would never know ๐Ÿคท.
  • The catch-all handler is declared for Exception but takes a RuntimeException parameter, and it sends e.getMessage() back to the caller on a 500, leaking internals.
  • The package is called excpetion. Yes. Typo'd. Since day one ๐Ÿ™ˆ.

Customer-statements has specific exceptions (DocumentNotFoundException, ForbiddenException, S3UploadExceptionโ€ฆ) each mapped to the right status (404, 403, 413, 503 when a downstream service is down), and the generic 500 hides the details. For the Email API I'd return a result that says which recipients got their email, and use a 502 or 207-style response when some didn't.

5. Tests that test something ๐Ÿงช

The Email API's only test is contextLoads(). Customer-statements has Mockito unit tests for StatementService and RedisService that check the happy path, the failure paths and the order things happen in. The Email API is small enough that a few tests would cover it: mock SesV2Client, check one email goes out per recipient, check a partial failure is reported, and check the template renders with sample data.

6. Consistent types ๐Ÿ”ข

Forex openingPrice is a BigDecimal, but stock open, pastOpen and percentageDifference are all Strings. Strings are easy to drop into a template, but they push all the number formatting onto whoever calls the API. Today I'd make them all BigDecimal and format them in the template.

Bonus round: the gateway I won't bring here ๐Ÿšช

Customer-statements has its own api-gateway module, and I'm quite proud of it. It's built on Spring Cloud Gateway and is the single front door for three services:

  • ๐Ÿ” It's an OAuth2 resource server, so every request needs a valid JWT from Dex, the OIDC provider. Only /api/auth/** is open, so people can log in.
  • ๐Ÿชช A ClaimToHeaderFilter decodes the token's sub claim into a user id and forwards it downstream as an X-User-Id header. The services behind it never touch tokens; they just read a header.
  • ๐Ÿ—บ๏ธ Routes are plain config: /api/auth/** goes to login-service, /api/statements/** to statement-service, /api/users/** to user-service.

Would I put that in front of the Email API? Nope ๐Ÿ™…. Here there's one service and one caller, and the caller is a machine, not a person logging in. AWS API Gateway plus a small API-key authoriser already does exactly that job, and a second always-on Spring app on Lambda would just add another cold start and more to maintain. Still, it's the clearest example of how much my thinking about auth and service boundaries has grown since the Email API.

Plot twist: Polygon is now Massive ๐Ÿฆฃ

While this project sat on the back burner ๐Ÿ”ฅ, my data provider changed its name. On 30 October 2025, Polygon.io became Massive, with a new API home at api.massive.com running alongside the old api.polygon.io.

The good news: my fetcher still calls api.polygon.io and it still works ๐ŸŽ‰. Massive said existing keys stay valid and the old domain keeps working "for an extended period". The not-so-good news: that same announcement said they plan to phase out the old endpoints in 2026, with plenty of notice first. When I checked their changelog in October 2026, there was still no shutdown date for api.polygon.io, and the daily open/close endpoint I use (/v1/open-close/{ticker}/{date}) is still in the docs with no deprecation notice.

So it isn't urgent, but it's on the list ๐Ÿ“. Because the paths are the same, the fix should be tiny: swap the base URL in the market-data Lambda to api.massive.com before Massive turns the old one off.

Infra things I'd do differently ๐Ÿ—๏ธ

It works, but I built parts of it while I was still learning the tools, and it shows in a few spots.

The gateway setup. The Email API lives on the API Gateway I'd already built for my Market Financials project, behind a catch-all ANY /{proxy+} route. I picked that while I was still figuring out API Gateway configuration. I understand it a lot better now, and my Statement Analysis Terraform is a better picture of how I'd set it up today.

Terraform layout. The market-financials Terraform is flat: one folder of .tf files split by resource type. For a project this small that's fine. Next time I'd use modules, which is how we structure infrastructure where I work and what I've moved to on newer projects.

The Rust authoriser. There's a Rust version of the authoriser in my GitHub, but it isn't deployed, and confession time: I didn't really write it. I was only a few days into The Rust Programming Language and getting it working with Lambda was beyond me at the time, so I had Claude generate it ๐Ÿค–. Since then I've worked through Codewars challenges and I'm getting comfy with Cargo. The plan is to bin that version and write my own.

Subscribers. There are two subscribers, and I add them by typing straight into DynamoDB. Fine for two people; anything bigger would need a proper subscribe flow.

That's a wrap ๐ŸŽฌ

From the inbox, nothing changed: same email, same numbers, same 9am ping ๐Ÿ“ฌ. Under the hood, it took me from one C# function to a scheduled Python job, an authoriser, a Spring Boot service on Lambda and the Terraform holding it all together. It got me more Java and Spring practice outside of work, which was the whole point. And looking back at it next to my promotion project is a nice reminder of how far I've come ๐ŸŒฑ.

Next up, whenever it leaves the back burner: the Massive URL swap, validation and proper errors, a few real tests, and a new endpoint the next time one of my side projects needs to send an email ๐Ÿ“ฎ. Personal projects don't need a big problem to be worth building โœจ.

All the code ๐Ÿง‘โ€๐Ÿ’ป