davideisinger.com

My personal website
Log | Files | Refs | README

index.md (8502B)


      1 ---
      2 title: "Email Photos to an S3 Bucket with AWS Lambda (with Cropping, in Ruby)"
      3 date: 2021-04-07T00:00:00+00:00
      4 draft: false
      5 canonical_url: https://www.viget.com/articles/email-photos-to-an-s3-bucket-with-aws-lambda-with-cropping-in-ruby/
      6 references:
      7 - title: "Ditherpunk — The article I wish I had about monochrome image dithering — surma.dev"
      8   url: https://surma.dev/things/ditherpunk/
      9   date: 2024-02-05T14:50:25Z
     10   file: surma-dev-e4sfuv.txt
     11 ---
     12 
     13 In my annual search for holiday gifts, I came across this [digital photo
     14 frame](https://auraframes.com/digital-frames/color/graphite) that lets
     15 you load photos via email. Pretty neat, but I ultimately didn't buy it
     16 for a few reason: 1) it's pretty expensive, 2) I'd be trusting my
     17 family's data to an unknown entity, and 3) if the company ever goes
     18 under or just decides to stop supporting the product, it might stop
     19 working or at least stop updating. But I got to thinking, could I build
     20 something like this myself? I'll save the full details for a later
     21 article, but the first thing I needed to figure out was how to get
     22 photos from an email into an S3 bucket that could be synced onto a
     23 device.
     24 
     25 I try to keep up with the various AWS offerings, and Lambda has been on
     26 my radar for a few years, but I haven't had the opportunity to use it
     27 in anger. Services like this really excel at the extremes of web
     28 software --- at the low end, where you don't want to incur the costs of
     29 an always-on server, and at the high-end, where you don't want to pay
     30 for a whole fleet of them. Most of our work falls in the middle, where
     31 developer time is way more costly than hosting infrastructure and so
     32 using a more full-featured stack running on a handful of conventional
     33 servers is usually the best option. But an email-to-S3 gateway is a
     34 perfect use case for on-demand computing.
     35 
     36 ## The Services
     37 
     38 To make this work, we need to connect several AWS services:
     39 
     40 -   [Route 53](https://aws.amazon.com/route53/) (for domain registration
     41     and DNS configuration)
     42 -   [SES](https://aws.amazon.com/ses/) (for setting up the email address
     43     and "rule set" that triggers the Lambda function)
     44 -   [S3](https://aws.amazon.com/s3/) (for storing the contents of the
     45     incoming emails as well as the resulting photos)
     46 -   [SNS](https://aws.amazon.com/sns/) (for notifying the Lambda
     47     function of an incoming email)
     48 -   [Lambda](https://aws.amazon.com/lambda) (to process the incoming
     49     email, extract the photos, crop them, and store the results)
     50 -   [CloudWatch](https://aws.amazon.com/cloudwatch) (for debugging
     51     issues with the code)
     52 -   [IAM](https://aws.amazon.com/iam) (for setting the appropriate
     53     permissions)
     54 
     55 It's a lot, to be sure, but it comes together pretty easily:
     56 
     57 1.  Create a couple buckets in S3, one to hold emails, the other to hold
     58     photos.
     59 2.  Register a domain ("hosted zone") in Route 53.
     60 3.  Go to Simple Email Service > Domains and verify a new domain,
     61     selecting the domain you just registered in Route 53.
     62 4.  Go to the SES "rule sets" interface and click "Create Rule."
     63     Give it a name and an email address you want to send your photos to.
     64 5.  For the rule action, pick "S3" and then the email bucket you
     65     created in step 1 (we have to use S3 rather than just calling the
     66     Lambda function directly because our emails exceed the maximum
     67     payload size). Make sure to add an SNS (Simple Notification Service)
     68     topic to go along with your S3 action, which is how we'll trigger
     69     our Lambda function.
     70 6.  Go to the Lambda interface and create a new function. Give it a name
     71     that makes sense for you and pick Ruby 2.7 as the language.
     72 7.  With your skeleton function created, click "Add Trigger" and
     73     select the SNS topic you created in step 5. You'll need to add
     74     ImageMagick as a layer[^1] and bump the memory and timeout (I used 512 MB
     75     and 30 seconds, respectively, but you should use whatever makes you
     76     feel good in your heart).
     77 8.  Create a couple environment variables: `BUCKET` should be name of
     78     the S3 bucket you want to upload photos to, and `AUTHORIZED_EMAILS`
     79     to hold all the valid email addresses separated by semicolons.
     80 9.  Give your function permissions to read and write to/from the two
     81     buckets.
     82 10. And finally, the code. We'll manage that locally rather than using
     83     the web-based interface since we need to include a couple gems.
     84 
     85 ## The Code
     86 
     87 So as I said literally one sentence ago, we manage the code for this
     88 Lambda function locally since we need to include a couple gems:
     89 [`mail`](https://github.com/mikel/mail) to parse the emails stored in S3
     90 and [`mini_magick`](https://github.com/minimagick/minimagick) to do the
     91 cropping. If you don't need cropping, feel free to leave that one out
     92 and update the code accordingly. Without further ado:
     93 
     94 ```ruby
     95 require 'json'
     96 require 'aws-sdk-s3'
     97 require 'mail'
     98 require 'mini_magick'
     99 
    100 BUCKET = ENV["BUCKET"]
    101 AUTHORIZED_EMAILS = ENV["AUTHORIZED_EMAILS"].split(";")
    102 
    103 def lambda_handler(event:, context:)
    104   message = JSON.parse(event["Records"][0]["Sns"]["Message"])
    105   s3_info = message["receipt"]["action"]
    106   client = Aws::S3::Client.new(region: "us-east-1")
    107 
    108   # Get the incoming email from S3
    109   object = client.get_object(
    110     bucket: s3_info["bucketName"],
    111     key: s3_info["objectKey"]
    112   )
    113 
    114   email = Mail.new(object.body.read)
    115   sender = email.from.first
    116 
    117   # Confirm that the sender is in the list, otherwise abort
    118   unless AUTHORIZED_EMAILS.include?(sender)
    119     puts "Unauthorized email: #{sender}"
    120     exit
    121   end
    122 
    123   # Get all the images out of the email
    124   attachments = email.parts.filter { |p| p.content_type =~ /^image/ }
    125 
    126   attachments.each do |attachment|
    127     # First, just put the original photo in the `photos` subdirectory
    128     client.put_object(
    129       body: attachment.body.to_s,
    130       bucket: BUCKET,
    131       key: "photos/#{attachment.filename}"
    132     )
    133 
    134     thumb = MiniMagick::Image.read(attachment.body.to_s)
    135 
    136     # Crop the photo down for displaying on a webpage
    137     thumb.combine_options do |i|
    138       i.auto_orient
    139       i.resize "440x264^"
    140       i.gravity "center"
    141       i.extent "440x264"
    142     end
    143 
    144     client.put_object(
    145       body: thumb.to_blob,
    146       bucket: BUCKET,
    147       key: "thumbs/#{attachment.filename}"
    148     )
    149 
    150     dithered = MiniMagick::Image.read(attachment.body.to_s)
    151 
    152     # Crop and dither the photo for displaying on an e-ink screen
    153     dithered.combine_options do |i|
    154       i.auto_orient
    155       i.resize "880x528^"
    156       i.gravity "center"
    157       i.extent "880x528"
    158       i.ordered_dither "o8x8"
    159       i.monochrome
    160     end
    161 
    162     client.put_object(
    163       body: dithered.to_blob,
    164       bucket: BUCKET,
    165       key: "dithered/#{attachment.filename}"
    166     )
    167 
    168     puts "Photo '#{attachment.filename}' uploaded"
    169   end
    170 
    171   {
    172     statusCode: 200,
    173     body: JSON.generate("#{attachments.size} photo(s) uploaded.")
    174   }
    175 end
    176 ```
    177 
    178 If you're unfamiliar with dithering, [here's a great
    179 post](https://surma.dev/things/ditherpunk/) with more info, but in
    180 short, it's a way to simulate grayscale with only black and white
    181 pixels like what you find on an e-ink/e-paper display.
    182 
    183 ## Deploying
    184 
    185 To deploy your code, you'll use the [AWS
    186 CLI](https://aws.amazon.com/cli/). [Here's a pretty good
    187 walkthrough](https://docs.aws.amazon.com/lambda/latest/dg/ruby-package.html)
    188 of how to do it but I'll summarize:
    189 
    190 1.  Install your gems locally with `bundle install --path vendor/bundle`.
    191 2.  Edit your code (in our case, it lives in `lambda_function.rb`).
    192 3.  Make a simple shell script that zips up your function and gems and
    193     sends it up to AWS:
    194 
    195 ```sh
    196 #!/bin/sh
    197 
    198 zip -r function.zip lambda_function.rb vendor 
    199   && aws lambda update-function-code 
    200   --function-name [lambda-function-name] 
    201   --zip-file fileb://function.zip
    202 ```
    203 
    204 And that's it! A simple, resilient, cheap way to email photos into an
    205 S3 bucket with no servers in sight (at least none you care about or have
    206 to manage).
    207 
    208 ------------------------------------------------------------------------
    209 
    210 In closing, this project was a great way to get familiar with Lambda and
    211 the wider AWS ecosystem. It came together in just a few hours and is
    212 still going strong several months later. My typical bill is something on
    213 the order of $0.50 per month. If anything goes wrong, I can pop into
    214 CloudWatch to view the result of the function, but so far,
    215 [so smooth]({{<dither_url smooth-yoda.jpg>}}).
    216 
    217 I'll be back in a few weeks detailing the rest of the project. Stay
    218 tuned!
    219 
    220 [^1]: I used the ARN `arn:aws:lambda:us-east-1:182378087270:layer:image-magick:1`