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`