Repository navigation
Expand file tree
/
Copy pathhide_my_email.py
More file actions
2285 lines (1978 loc) · 93.7 KB
/
Copy pathhide_my_email.py
File metadata and controls
2285 lines (1978 loc) · 93.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Hide My Email: addresses on own domains that forward to personal addresses.
One function serves both sides. With MAIL_ENABLED off (the public demo) there
is no mail at all: SES neither delivers nor is asked about addresses, and the
API manages aliases, keys, correspondents and the message log only, and no
new message goes out. MAX_ALIASES, MAX_TOKENS and MAX_RELAYS cap what
visitors of the demo can create.
Mail. SES stores every message for the alias domains in S3 and then invokes
the function with the receipt. Each recipient is handled on its own:
- an active alias (quiet-river-42@domain): the message is forwarded to the
alias's own forwarding address, or to FORWARD_TO when it has none. From
becomes a relay address with the sender's address in it, as Apple does
(office_at_shop_test_<code>@domain), so that a reply comes back here; the
code is kept in DynamoDB with the original sender. r-<code>@domain, the
older form, still works;
- a relay address, sent from the address the alias forwards to: the reply
goes to the original sender from the alias, with that address taken out of
the text.
Everything SES sends is signed: replies and new messages carry an OpenPGP
signature (PGP/MIME, RFC 3156) by the alias's own Ed25519 key (pgp.py),
forwarded mail one by the key of the relay address it comes from. Each key is
published in the Web Key Directory of the alias domain:
/.well-known/openpgpkey/hu/<hash> on the page of each domain, which CloudFront
passes here, so that mail clients find it by the address and check the
signature. The private key is never stored: its seed is derived from the
secret in PGP_SECRET_PARAMETER and the alias, the alias keeps only the time
the key was made (pgp_created), which fixes the fingerprint. The key has no
expiry and goes with the alias. The page can stop the signatures (SETTING
pgp); PGP=false turns all of it off, the directory too.
A conversation may also start here. The page adds a correspondent to an
alias, which makes the relay address of the pair without a message from
them: mail to it from the forwarding address goes to them from the alias, as
a reply would. The page also sends a new text message from an alias, and the
recipient becomes a correspondent of it.
Aliases live on one or more domains: DOMAIN, which is also the page, and the
others in DOMAINS. A local part is unique across them: an alias belongs to
one domain, DOMAIN unless it names another, and mail to the same name on
another domain is dropped. Aliases are managed on the page and through the
API only. Anything else is dropped. A sender counts only when SES saw DMARC
pass, or SPF pass for the same address when its domain has no DMARC record.
Every forwarded message and every reply goes to the message log: a record in
DynamoDB, a copy of the original under MESSAGES_PREFIX in the mail bucket and
each attachment under ATTACHMENTS_PREFIX. Only the page deletes them; the
received copies under MAIL_PREFIX expire. CloudFront serves the attachments
at /attachments/* to whoever holds the signed cookie the API sets.
API. The web page calls /api/... through API Gateway with a Cognito token:
list, create, change and delete aliases, ask SES to verify a forwarding
address while the account is in the SES sandbox, find the conversation behind
a relay address, list, add and remove the correspondents of an alias, send a
new message from an alias, read and delete the message log, manage API keys,
turn the PGP signatures on and off.
/api/v1/aliases is the part of the addy.io API that the username generator
of Bitwarden and the addy.io browser extension use. API Gateway lets it
through without a Cognito token; the function itself wants an API key made on
the page.
DynamoDB: pk = ALIAS, sk = local part (domain, comment, forward_to,
sender_name, active, pinned, created_at, updated_at, forwarded, last_at,
pgp_created);
pk = TOKEN, sk = SHA-256 of an API key (id, name, created_by, created_at,
last_used_at); pk = REPLY, sk = code (alias, sender, name); pk = MSG, sk = id,
which sorts by time (direction, alias, alias_domain, code, relay, from_*,
to_*, delivered_to, subject, at, size, spam, attachments); pk = SETTING,
sk = pgp (signing, updated_at); pk = RECIPIENT, sk = a forwarding address
added through the addy.io API (created_at, description, updated_at).
"""
import base64
import email
import email.message
import email.policy
import email.utils
import hashlib
import hmac
import html.parser
import json
import logging
import os
import quopri
import re
import secrets
import string
import time
import urllib.request
from datetime import datetime, timezone
from urllib.parse import quote, unquote
import pgp
logger = logging.getLogger()
logger.setLevel(logging.INFO)
REPLY_PREFIX = "r-"
# Keys are letters and digits only, at least 40: the addy.io browser extension
# refuses anything else. Keys made before were hme_ and URL-safe characters;
# they still work, the extension just cannot take them.
TOKEN_PREFIX = "hme"
# The addy.io server version /api/v1/app-version reports. The addy.io apps
# refuse a self-hosted server older than they need (MINIMUMVERSIONCODE in
# AddyIo.swift and AddyIo.kt: 1.7.3); raise it when a newer app asks for more
# and still works with this API. 9999 would mean app.addy.io itself.
ADDY_VERSION = "1.7.3"
TOKEN_ALPHABET = string.ascii_letters + string.digits
# The addy.io API is called from Bitwarden's web vault and extension, other
# origins. It takes a key in a header, never a cookie, so any origin may call.
CORS_HEADERS = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
"access-control-allow-headers": "Authorization, Content-Type, Accept, X-Requested-With, X-Requested-From",
"access-control-max-age": "86400",
}
CODE_LENGTH = 12
# A relay address: the sender's address in the local part, "_", the code.
RELAY_RE = re.compile(rf"^[a-z0-9_]+_([a-z2-7]{{{CODE_LENGTH}}})$")
MESSAGE_PAGE = 50
# A new message from the page: a subject on one line and plain text.
SUBJECT_LIMIT = 300
TEXT_LIMIT = 100_000
# The request with such a text, room left for JSON escapes.
MESSAGE_BODY_LIMIT = 4 * TEXT_LIMIT
# How long the signed cookie for /attachments/* lasts; every successful API
# answer renews it.
COOKIE_SECONDS = 3600
# DER prefix of a SHA-1 DigestInfo for PKCS#1 v1.5 signatures.
SHA1_DIGEST_INFO = bytes.fromhex("3021300906052b0e03021a05000414")
# Names that are not given out as aliases: the usual role addresses.
RESERVED = {"noreply", "postmaster", "abuse", "hostmaster", "webmaster", "bounce", "mailer-daemon"}
ALIAS_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{0,38}[a-z0-9])?$")
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[^@\s]{2,}$")
# Headers that name an address SES would have to send for, or that speak of
# the checks of the original message (its signature, SPF, ARC, the receipt by
# SES). Left in a forwarded message they contradict the new From and its DKIM,
# which Gmail takes for spoofing; left in a reply they show the personal
# address (smtp.mailfrom=me@…).
DROPPED_HEADERS = (
"Return-Path", "Sender", "DKIM-Signature", "DomainKey-Signature",
"Authentication-Results", "Received-SPF", "X-Original-Authentication-Results", "X-Received",
)
DROPPED_PREFIXES = ("ARC-", "X-SES-", "X-Google-", "X-Gm-", "X-MS-Exchange-Organization-")
# A reply also loses the trace of how it left the owner's mailbox: the hosts,
# and with some providers the owner's IP address.
REPLY_DROPPED_HEADERS = ("Received", "X-Originating-IP", "X-Mailer-IP")
ADJECTIVES = (
"amber", "bold", "brave", "bright", "brisk", "calm", "clever", "cool", "crisp", "daring",
"eager", "early", "fair", "fancy", "fast", "fresh", "gentle", "glad", "golden", "grand",
"green", "happy", "honest", "humble", "jolly", "keen", "kind", "late", "lively", "lucky",
"mellow", "merry", "mild", "misty", "modest", "noble", "polite", "proud", "quick", "quiet",
"rapid", "rare", "ready", "royal", "rustic", "silent", "silver", "simple", "sleek", "smart",
"snowy", "solid", "steady", "sunny", "swift", "tidy", "true", "vivid", "warm", "wild",
"wise", "witty", "young", "zesty",
)
NOUNS = (
"anchor", "apple", "arrow", "badger", "beacon", "birch", "breeze", "brook", "cactus", "canyon",
"cedar", "cliff", "cloud", "comet", "coral", "crane", "delta", "dune", "eagle", "ember",
"falcon", "fern", "field", "fjord", "forest", "fox", "garden", "glacier", "harbor", "hawk",
"heron", "hill", "island", "lake", "lantern", "maple", "meadow", "moon", "moss", "nest",
"oak", "ocean", "orchid", "otter", "owl", "pebble", "pine", "planet", "prairie", "rain",
"raven", "reef", "river", "rock", "sail", "shore", "sky", "spruce", "star", "stone",
"summit", "tide", "valley", "willow",
)
_s3 = None
_ses = None
_ses_v1 = None
_table = None
_ssm = None
_signing_key = None
_pgp_secret = None
# Errors of the API: a code, which the page translates, and an English text.
ERRORS = {
"alias_exists": "This address already exists",
"alias_not_found": "Address not found",
"not_found": "Not found",
"api_key_only": "Sign in with an API key: make one in the settings of the page (API keys)",
"not_supported": "This server does not have that: it is a self-hosted relay with its own forwarding addresses and alias domains",
"recipient_not_found": "No such recipient",
"recipient_in_use": "This recipient is the default one or an alias forwards to it",
"too_many_recipients": "Too many recipients",
"bad_active": "active must be true or false",
"bad_signing": "signing must be true or false",
"nothing_to_change": "Nothing to change",
"address_required": "An email address is needed",
"message_not_found": "Message not found",
"relay_not_found": "No such reply address",
"email_not_verified": "Email not verified",
"bad_local": "Address name: Latin letters, digits, dot, hyphen and underscore, up to 40 characters; role names (noreply, postmaster…), the r- prefix and an ending like a reply address (_ and 12 characters) are taken",
"bad_comment_type": "The comment must be text",
"comment_too_long": "The comment is longer than 500 characters",
"bad_sender_name_type": "The sender name must be text",
"bad_sender_name": "Sender name: up to 100 characters, no @, quotes or angle brackets",
"bad_forward": "The forwarding address must be an email address",
"forward_loop": "Mail cannot be forwarded to an address of this same domain",
"too_large": "Request too large",
"bad_json_object": "A JSON object was expected",
"bad_json": "Invalid JSON",
"internal": "Internal error",
"mail_disabled": "Mail is turned off here: nothing is received or sent",
"limit_reached": "The limit of this installation is reached; delete something first",
"bad_token": "Unauthenticated: a valid API key is needed",
"bad_token_name": "Key name: up to 100 characters of text",
"token_not_found": "API key not found",
"bad_domain": "Unknown domain",
"bad_format": "format must be random_words or custom",
"bad_recipient": "The recipient must be an email address outside the alias domains",
"bad_name": "Name: up to 100 characters, no @, quotes or angle brackets",
"bad_subject": "Subject: text on one line, up to 300 characters",
"bad_text": "The message must be text, up to 100,000 characters",
"send_rejected": "SES did not send the message; while the account is in the SES sandbox the recipient has to be verified",
}
class ApiError(Exception):
def __init__(self, status, code):
super().__init__(ERRORS[code])
self.status = status
self.code = code
self.message = ERRORS[code]
def handler(event, context):
records = event.get("Records") if isinstance(event, dict) else None
if records and "ses" in records[0]:
return receive(records)
if isinstance(event, dict) and "requestContext" in event:
return api(event)
return {"ok": True}
# Mail ---------------------------------------------------------------------
def receive(records):
for record in records:
mail = record["ses"].get("mail") or {}
receipt = record["ses"].get("receipt") or {}
raw = s3().get_object(Bucket=env("MAIL_BUCKET"), Key=env("MAIL_PREFIX") + mail["messageId"])["Body"].read()
for recipient in receipt.get("recipients", []):
try:
route(recipient.lower(), raw, mail, receipt)
except Exception:
# One recipient failing must not stop the others; the message
# stays in S3 for the retention period.
logger.exception("recipient %s failed for message %s", recipient, mail.get("messageId"))
return {"ok": True}
def route(recipient, raw, mail, receipt):
local, _, domain = recipient.partition("@")
if domain not in alias_domains():
return "other domain"
code = relay_code(local)
if code:
return reply(code, raw, mail, receipt)
alias = get_alias(local)
if not alias or not alias.get("active") or alias_domain(alias) != domain:
logger.info("dropped: no active alias %s", local)
return "unknown alias"
return forward(alias, raw, mail, receipt)
def forward(alias, raw, mail, receipt):
local = alias["sk"]
if verdict(receipt, "virusVerdict") == "FAIL":
logger.info("dropped: virus in a message for %s", local)
return "virus"
message = parse(raw)
name, sender = first_address(message.get("Reply-To")) if message.get("Reply-To") else ("", "")
from_name, from_address = first_address(message.get("From"))
if not sender:
name, sender = from_name, from_address
if not sender:
logger.info("dropped: no sender in a message for %s", local)
return "no sender"
code = reply_code(local, sender)
table().put_item(Item={
"pk": "REPLY",
"sk": code,
"alias": local,
"sender": sender,
"name": name,
"updated_at": now_iso(),
})
subject = str(message.get("Subject", ""))
spam = verdict(receipt, "spamVerdict") == "FAIL"
drop_headers(message, ("From", "Reply-To") + DROPPED_HEADERS)
domain = alias_domain(alias)
relay = relay_address(sender, code, domain)
relay_from = email.utils.formataddr((shown_sender(from_name, from_address or sender), relay))
message["From"] = relay_from
message["X-Hide-My-Email-Alias"] = f"{local}@{domain}"
# A mark in a header, not "[SPAM]" in the subject: the subject itself would
# make Gmail file the message as spam. A filter can use the header.
if spam:
message["X-Hide-My-Email-Spam"] = "yes"
send(relay_from, [destination(alias)], signed(message, alias, relay.split("@")[0]))
table().update_item(
Key={"pk": "ALIAS", "sk": local},
UpdateExpression="SET #forwarded = if_not_exists(#forwarded, :zero) + :one, #last_at = :now",
ExpressionAttributeNames={"#forwarded": "forwarded", "#last_at": "last_at"},
ExpressionAttributeValues={":zero": 0, ":one": 1, ":now": now_iso()},
)
log_message(mail, raw, {
"direction": "in",
"alias": local,
"alias_domain": domain,
"code": code,
"relay": relay,
"from_name": from_name,
"from_address": from_address or sender,
"to_name": "",
"to_address": f"{local}@{domain}",
"delivered_to": destination(alias),
"subject": subject,
"spam": spam,
})
return "forwarded"
def reply(code, raw, mail, receipt):
message = parse(raw)
item = table().get_item(Key={"pk": "REPLY", "sk": code}).get("Item")
if not item:
logger.info("dropped: unknown reply code %s", code)
return "unknown code"
alias = get_alias(item["alias"])
owner = destination(alias) if alias else forward_to()
if not from_owner(message, mail, receipt, owner):
logger.info("dropped: reply to %s not from %s", code, owner)
return "not the owner"
domain = alias_domain(alias) if alias else domain_name()
alias_address = f"{item['alias']}@{domain}"
name = reply_name(alias, message)
# Only the original sender gets the reply: copies would show the alias to
# people who never wrote to it.
drop_headers(message, ("From", "To", "Cc", "Bcc", "Reply-To") + DROPPED_HEADERS + REPLY_DROPPED_HEADERS)
alias_from = email.utils.formataddr((name, alias_address))
message["From"] = alias_from
message["To"] = email.utils.formataddr((item.get("name") or "", item["sender"]))
hide_owner(message, owner, alias_address)
send(alias_from, [item["sender"]], signed(message, alias))
log_message(mail, raw, {
"direction": "out",
"alias": item["alias"],
"alias_domain": domain,
"code": code,
"relay": relay_address(item["sender"], code, domain),
"from_name": name,
"from_address": alias_address,
"to_name": item.get("name") or "",
"to_address": item["sender"],
"delivered_to": item["sender"],
"subject": str(message.get("Subject", "")),
"spam": False,
})
return "replied"
# The log of forwarded messages and replies: a record to list and a copy of
# the original message to read, kept until the page deletes them. The message
# has gone out already, so a failure here is only logged.
def log_message(mail, raw, fields):
return store_log(raw, fields, received=mail.get("messageId"))
# A received message is copied from MAIL_PREFIX; one written here (received is
# None) is stored as it was sent. Returns the id in the log, or None.
def store_log(raw, fields, received=None):
at = now_iso()
message_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + "-" + secrets.token_hex(4)
try:
if received:
s3().copy_object(
Bucket=env("MAIL_BUCKET"),
Key=env("MESSAGES_PREFIX") + message_id,
CopySource={"Bucket": env("MAIL_BUCKET"), "Key": env("MAIL_PREFIX") + received},
)
else:
s3().put_object(Bucket=env("MAIL_BUCKET"), Key=env("MESSAGES_PREFIX") + message_id, Body=raw)
count = store_attachments(message_id, parse(raw))
table().put_item(Item={"pk": "MSG", "sk": message_id, "at": at, "size": len(raw), "attachments": count, **fields})
return message_id
except Exception:
logger.exception("message %s was not logged", received or message_id)
return None
# The name shown with the alias in a reply: the alias's own sender name, or
# else the name the owner's mail client put in From.
def reply_name(alias, message):
if alias and alias.get("sender_name"):
return alias["sender_name"]
return first_address(message.get("From"))[0].strip()
# The quoted text of a reply ("On …, Name <me@example.com> wrote") would show
# the personal address; it is replaced with the alias in every text part.
def hide_owner(message, owner, alias_address):
pattern = re.compile(re.escape(owner), re.IGNORECASE)
for part in message.walk():
if part.get_content_maintype() != "text" or part.is_attachment():
continue
try:
text = part.get_content()
except (LookupError, ValueError):
continue
if pattern.search(text):
part.set_content(pattern.sub(alias_address, text), subtype=part.get_content_subtype(), charset="utf-8")
# The sender's own message: From and the envelope are the expected address, and
# SES saw DMARC pass, or SPF pass for that same address when its domain has no
# DMARC record.
def from_owner(message, mail, receipt, owner):
_, from_address = first_address(message.get("From"))
owner = owner.lower()
if from_address != owner or (mail.get("source") or "").lower() != owner:
return False
if verdict(receipt, "dmarcVerdict") == "PASS":
return True
return verdict(receipt, "dmarcVerdict") == "GRAY" and verdict(receipt, "spfVerdict") == "PASS"
# Aliases ------------------------------------------------------------------
def get_alias(local):
return table().get_item(Key={"pk": "ALIAS", "sk": local}).get("Item")
# The domain of an alias: its own, or DOMAIN for aliases made before there
# were more.
def alias_domain(alias):
return (alias.get("domain") or domain_name()).lower()
def alias_address(alias):
return f"{alias['sk']}@{alias_domain(alias)}"
def destination(alias):
return (alias.get("forward_to") or forward_to()).lower()
def create_alias(comment="", forward=None, local=None, sender_name="", domain=None):
if limit_reached("ALIAS", "MAX_ALIASES"):
raise ApiError(429, "limit_reached")
for _ in range(1 if local else 20):
name = local or f"{secrets.choice(ADJECTIVES)}-{secrets.choice(NOUNS)}-{secrets.randbelow(90) + 10}"
now = now_iso()
item = {"pk": "ALIAS", "sk": name, "comment": comment, "active": True, "created_at": now, "updated_at": now}
if forward:
item["forward_to"] = forward
if domain and domain != domain_name():
item["domain"] = domain
if sender_name:
item["sender_name"] = sender_name
try:
table().put_item(Item=item, ConditionExpression="attribute_not_exists(sk)")
return item
except Exception as exc:
if error_code(exc) != "ConditionalCheckFailedException":
raise
if local:
raise ApiError(409, "alias_exists")
raise RuntimeError("no free alias after 20 tries")
# Field names go through ExpressionAttributeNames: "comment" is a reserved
# word of DynamoDB and fails as a bare name.
def change_alias(local, changes):
sets = ["#updated_at = :now"]
removes = []
names = {"#updated_at": "updated_at"}
values = {":now": now_iso()}
for field in ("comment", "active", "pinned"):
if field in changes:
sets.append(f"#{field} = :{field}")
names[f"#{field}"] = field
values[f":{field}"] = changes[field]
# An empty forwarding address or sender name means the default one.
for field in ("forward_to", "sender_name"):
if field not in changes:
continue
names[f"#{field}"] = field
if changes[field]:
sets.append(f"#{field} = :{field}")
values[f":{field}"] = changes[field]
else:
removes.append(f"#{field}")
expression = "SET " + ", ".join(sets) + (" REMOVE " + ", ".join(removes) if removes else "")
try:
return table().update_item(
Key={"pk": "ALIAS", "sk": local},
UpdateExpression=expression,
ConditionExpression="attribute_exists(sk)",
ExpressionAttributeNames=names,
ExpressionAttributeValues=values,
ReturnValues="ALL_NEW",
).get("Attributes", {})
except Exception as exc:
if error_code(exc) == "ConditionalCheckFailedException":
raise ApiError(404, "alias_not_found") from exc
raise
def all_aliases():
return sorted(query_all("ALIAS"), key=lambda item: item.get("created_at") or "", reverse=True)
# Every item of one partition, page after page.
def query_all(pk):
from boto3.dynamodb.conditions import Key
items = []
kwargs = {"KeyConditionExpression": Key("pk").eq(pk)}
while True:
page = table().query(**kwargs)
items.extend(page.get("Items", []))
if not page.get("LastEvaluatedKey"):
return items
kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"]
# API ----------------------------------------------------------------------
def api(event):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
parts = [unquote(part) for part in (event.get("rawPath") or "/").split("/") if part]
if parts[:2] == ["api", "v1"]:
return public_api(method, parts[2:], event)
if parts[:2] == ["api", "auth"]:
return addy_auth(method, parts[2:], event)
if parts[:2] == [".well-known", "openpgpkey"] and pgp_enabled():
return web_key_directory(parts[2:], event)
try:
if parts[:1] != ["api"]:
raise ApiError(404, "not_found")
actor(event)
result = api_route(method, parts[1:], event)
if parts[1:] != ["session"]:
attach_cookies(result)
except ApiError as exc:
result = response(exc.status, {"error": exc.message, "code": exc.code})
except json.JSONDecodeError:
result = response(400, {"error": ERRORS["bad_json"], "code": "bad_json"})
except Exception:
logger.exception("unhandled")
result = response(500, {"error": ERRORS["internal"], "code": "internal"})
logger.info("%s %s -> %s", method, event.get("rawPath"), result["statusCode"])
return result
def api_route(method, parts, event):
if parts == ["me"] and method == "GET":
return response(200, {
"email": actor(event),
"domain": domain_name(),
"domains": alias_domains(),
"forward_to": forward_to(),
"production": production_access(),
"mail": mail_enabled(),
"pgp": {"signing": signing_on()} if pgp_enabled() else None,
})
if parts == ["version"] and method == "GET":
return response(200, version_info())
if parts == ["settings", "pgp"] and method == "PUT":
return response(200, set_signing(body_json(event).get("signing")))
if parts == ["aliases"] and method == "GET":
items = all_aliases()
addresses = {forward_to()} | {destination(item) for item in items}
return response(200, {
"items": [public_alias(item) for item in items],
"destinations": {address: destination_status(address) for address in sorted(addresses)},
"production": production_access(),
})
if parts == ["aliases"] and method == "POST":
payload = body_json(event)
local = payload.get("local")
if local:
local = clean_local(local)
item = create_alias(
comment=clean_comment(payload.get("comment")),
forward=clean_forward(payload.get("forward_to")),
local=local,
sender_name=clean_sender_name(payload.get("sender_name")),
domain=clean_domain(payload.get("domain")),
)
return response(201, public_alias(item))
if len(parts) == 2 and parts[0] == "aliases" and method == "PATCH":
payload = body_json(event)
changes = {}
if "comment" in payload:
changes["comment"] = clean_comment(payload["comment"])
if "forward_to" in payload:
changes["forward_to"] = clean_forward(payload["forward_to"])
if "sender_name" in payload:
changes["sender_name"] = clean_sender_name(payload["sender_name"])
if "active" in payload:
if not isinstance(payload["active"], bool):
raise ApiError(400, "bad_active")
changes["active"] = payload["active"]
if not changes:
raise ApiError(400, "nothing_to_change")
return response(200, public_alias(change_alias(parts[1].lower(), changes)))
if len(parts) == 2 and parts[0] == "aliases" and method == "DELETE":
delete_alias(parts[1])
return response(200, {"deleted": parts[1].lower()})
if parts == ["tokens"] and method == "GET":
items = sorted(query_all("TOKEN"), key=lambda item: item.get("created_at") or "", reverse=True)
return response(200, {"items": [public_token(item) for item in items]})
if parts == ["tokens"] and method == "POST":
return response(201, create_token(clean_token_name(body_json(event).get("name")), actor(event)))
if len(parts) == 2 and parts[0] == "tokens" and method == "DELETE":
found = [item for item in query_all("TOKEN") if item.get("id") == parts[1]]
if not found:
raise ApiError(404, "token_not_found")
table().delete_item(Key={"pk": "TOKEN", "sk": found[0]["sk"]})
return response(200, {"deleted": parts[1]})
if parts == ["destinations"] and method == "POST":
if not mail_enabled():
raise ApiError(400, "mail_disabled")
address = clean_forward(body_json(event).get("address"))
if not address:
raise ApiError(400, "address_required")
# SES sends a link to the address; asking again sends it again.
ses_v1().verify_email_identity(EmailAddress=address)
return response(200, {"address": address, "status": destination_status(address)})
if len(parts) == 2 and parts[0] == "relays" and method == "GET":
return response(200, find_relay(parts[1]))
if parts == ["relays"] and method == "POST":
payload = body_json(event)
alias = existing_alias(str(payload.get("alias") or ""))
address = clean_recipient(payload.get("address"))
name = clean_name(payload.get("name"))
if limit_reached("REPLY", "MAX_RELAYS"):
raise ApiError(429, "limit_reached")
return response(201, add_relay(alias, address, name))
if len(parts) == 2 and parts[0] == "relays" and method == "DELETE":
return response(200, delete_relay(parts[1]))
if len(parts) == 3 and parts[0] == "aliases" and parts[2] == "key" and method == "GET":
return response(200, public_signing_key(existing_alias(parts[1])))
if len(parts) == 3 and parts[0] == "aliases" and parts[2] == "relays" and method == "GET":
return response(200, {"items": alias_relays(existing_alias(parts[1]))})
if parts == ["messages"] and method == "GET":
params = event.get("queryStringParameters") or {}
items, cursor = find_messages(params.get("alias", ""), params.get("q", ""), params.get("cursor", ""), MESSAGE_PAGE)
return response(200, {"items": [public_message(item) for item in items], "cursor": cursor})
if parts == ["messages"] and method == "POST":
return response(201, send_new(body_json(event, limit=MESSAGE_BODY_LIMIT)))
if parts == ["messages"] and method == "DELETE":
params = event.get("queryStringParameters") or {}
items, _ = find_messages(params.get("alias", ""), params.get("q", ""))
delete_messages(items)
return response(200, {"deleted": len(items)})
if len(parts) == 2 and parts[0] == "messages" and method == "GET":
return response(200, read_message(parts[1]))
if len(parts) == 2 and parts[0] == "messages" and method == "DELETE":
item = table().get_item(Key={"pk": "MSG", "sk": parts[1]}).get("Item")
if not item:
raise ApiError(404, "message_not_found")
delete_messages([item])
return response(200, {"deleted": 1})
# Sign-out: the attachment cookie goes too.
if parts == ["session"] and method == "DELETE":
return {**response(200, {"ok": True}), "cookies": attachment_cookies(expired=True)}
raise ApiError(404, "not_found")
def delete_alias(local):
try:
table().delete_item(Key={"pk": "ALIAS", "sk": local.lower()}, ConditionExpression="attribute_exists(sk)")
except Exception as exc:
if error_code(exc) == "ConditionalCheckFailedException":
raise ApiError(404, "alias_not_found") from exc
raise
# API keys ---------------------------------------------------------------------
#
# A key is hme and 48 random letters and digits; only its SHA-256 is kept, so
# the table cannot give a key away. The page shows a new key once.
def token_hash(token):
return hashlib.sha256(token.encode()).hexdigest()
def create_token(name, created_by):
if limit_reached("TOKEN", "MAX_TOKENS"):
raise ApiError(429, "limit_reached")
token = TOKEN_PREFIX + "".join(secrets.choice(TOKEN_ALPHABET) for _ in range(48))
digest = token_hash(token)
item = {"pk": "TOKEN", "sk": digest, "id": digest[:12], "name": name, "created_by": created_by, "created_at": now_iso()}
table().put_item(Item=item)
return {**public_token(item), "token": token}
def public_token(item):
return {
"id": item["id"],
"name": item.get("name") or "",
"created_by": item.get("created_by") or "",
"created_at": item.get("created_at") or "",
"last_used_at": item.get("last_used_at") or "",
}
def clean_token_name(value):
if value in (None, ""):
return "API"
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 100:
raise ApiError(400, "bad_token_name")
return " ".join(value.split())
def token_owner(event):
header = (event.get("headers") or {}).get("authorization") or ""
scheme, _, token = header.partition(" ")
token = token.strip()
if scheme.lower() != "bearer" or not token.startswith(TOKEN_PREFIX):
raise ApiError(401, "bad_token")
item = table().get_item(Key={"pk": "TOKEN", "sk": token_hash(token)}).get("Item")
if not item:
raise ApiError(401, "bad_token")
try:
table().update_item(
Key={"pk": "TOKEN", "sk": item["sk"]},
UpdateExpression="SET #last_used_at = :now",
ExpressionAttributeNames={"#last_used_at": "last_used_at"},
ExpressionAttributeValues={":now": now_iso()},
)
except Exception:
logger.exception("last use of key %s was not saved", item.get("id"))
return item
# The addy.io API ----------------------------------------------------------------
#
# What Bitwarden's generator and the addy.io browser extension call, in the
# shapes they read. Bitwarden only POSTs /aliases with {"domain",
# "description"} and reads data.email. The extension signs in with
# /domain-options, loads /recipients and /labels, lists /aliases with
# filter[...], sort and page[...], and creates, edits, turns on and off, pins
# and deletes aliases. The addy.io apps for Android and iOS sign in with
# /account-details and /api-token-details, check /app-version against the
# server version they need, and open lists this relay does not have
# (usernames, domains, rules, failed deliveries, blocklist, notifications),
# which come back empty. The iOS app decodes strictly: fields it declares
# as strings or booleans must be there and must not be null, times are
# "YYYY-MM-DD HH:MM:SS" and a page has meta.links and meta.path. There are no labels here: the list is empty and label
# changes are accepted and dropped. Every random format (random_words,
# random_characters, uuid) gives the usual random name. Errors are
# {"message": ...} as in addy.io; validation errors also carry "errors".
def public_api(method, parts, event):
if method == "OPTIONS":
return {"statusCode": 204, "headers": dict(CORS_HEADERS), "body": ""}
try:
key = token_owner(event)
_addy.clear()
result = public_route(method, parts, event, key)
logger.info("key %s: %s %s", key["id"], method, "/".join(parts))
except ApiError as exc:
payload = {"message": exc.message, "code": exc.code}
if exc.status == 422:
payload["errors"] = {exc.code: [exc.message]}
result = response(exc.status, payload)
except json.JSONDecodeError:
result = response(400, {"message": ERRORS["bad_json"], "code": "bad_json"})
except Exception:
logger.exception("unhandled")
result = response(500, {"message": ERRORS["internal"], "code": "internal"})
result["headers"] = {**result["headers"], **CORS_HEADERS}
logger.info("%s /api/v1/%s -> %s", method, "/".join(parts), result["statusCode"])
return result
# /api/auth of addy.io. The apps sign out with POST /logout and leave the
# account only on a 204: the key they send is revoked, as addy.io revokes the
# token of a device that signs out. A key that is already gone signs out too.
# Signing in by password (/login, /mfa) is not offered here: the apps take
# an API key made on the page.
def addy_auth(method, parts, event):
if method == "OPTIONS":
return {"statusCode": 204, "headers": dict(CORS_HEADERS), "body": ""}
if parts == ["logout"] and method == "POST":
try:
key = token_owner(event)
except ApiError:
key = None
if key:
table().delete_item(Key={"pk": "TOKEN", "sk": key["sk"]})
logger.info("key %s revoked: signed out", key["id"])
result = {"statusCode": 204, "headers": {"cache-control": "no-store"}, "body": ""}
elif parts in (["login"], ["mfa"]) and method == "POST":
result = response(422, {"message": ERRORS["api_key_only"], "code": "api_key_only", "errors": {"api_key_only": [ERRORS["api_key_only"]]}})
else:
result = response(404, {"message": ERRORS["not_found"], "code": "not_found"})
result["headers"] = {**result["headers"], **CORS_HEADERS}
logger.info("%s /api/auth/%s -> %s", method, "/".join(parts), result["statusCode"])
return result
def no_content():
return {"statusCode": 204, "headers": {"cache-control": "no-store"}, "body": ""}
def public_route(method, parts, event, key=None):
params = event.get("queryStringParameters") or {}
if parts == ["account-details"] and method == "GET":
return response(200, {"data": addy_account(key or {})})
if parts == ["api-token-details"] and method == "GET":
return response(200, {"name": (key or {}).get("name") or "", "created_at": addy_time((key or {}).get("created_at")) or "", "expires_at": None})
if parts == ["app-version"] and method == "GET":
major, minor, patch = (int(part) for part in ADDY_VERSION.split("."))
return response(200, {"version": ADDY_VERSION, "major": major, "minor": minor, "patch": patch})
if parts in (["usernames"], ["rules"], ["failed-deliveries"], ["blocklist"], ["account-notifications"]) and method == "GET":
return response(200, {"data": []})
if parts == ["domain-options"] and method == "GET":
return response(200, {
"data": alias_domains(),
"sharedDomains": [],
"defaultAliasDomain": domain_name(),
"defaultAliasFormat": "random_words",
})
if parts == ["recipients"] and method == "GET":
return response(200, {"data": [addy_recipient(address) for address in sorted(recipient_addresses())]})
if len(parts) == 2 and parts[0] == "recipients" and method == "GET":
return response(200, {"data": addy_recipient(known_recipient(parts[1]))})
if parts == ["labels"] and method == "GET":
return response(200, {"data": []})
if parts == ["aliases"] and method == "GET":
return response(200, addy_list(params))
if parts == ["aliases"] and method == "POST":
return response(201, {"data": addy_alias(addy_create(body_json(event)))})
if len(parts) == 2 and parts[0] == "aliases" and method == "GET":
return response(200, {"data": addy_alias(existing_alias(parts[1]))})
if len(parts) == 2 and parts[0] == "aliases" and method == "DELETE":
delete_alias(parts[1])
return no_content()
if parts == ["active-aliases"] and method == "POST":
return response(200, {"data": addy_alias(change_alias(str(body_json(event).get("id") or "").lower(), {"active": True}))})
if len(parts) == 2 and parts[0] == "active-aliases" and method == "DELETE":
change_alias(parts[1].lower(), {"active": False})
return no_content()
if parts == ["pinned-aliases"] and method == "POST":
return response(200, {"data": addy_alias(change_alias(str(body_json(event).get("id") or "").lower(), {"pinned": True}))})
if len(parts) == 2 and parts[0] == "pinned-aliases" and method == "DELETE":
change_alias(parts[1].lower(), {"pinned": False})
return no_content()
if parts == ["alias-labels"] and method == "POST":
return response(200, {"data": addy_alias(existing_alias(str(body_json(event).get("alias_id") or "")))})
if len(parts) == 2 and parts[0] == "aliases" and method == "PATCH":
payload = body_json(event)
changes = {}
if "description" in payload:
description = payload.get("description")
changes["comment"] = clean_comment(description[:500] if isinstance(description, str) else description)
if "from_name" in payload:
changes["sender_name"] = clean_sender_name(payload.get("from_name"))
return response(200, {"data": addy_alias(change_alias(parts[1].lower(), changes) if changes else existing_alias(parts[1]))})
if len(parts) == 3 and parts[0] == "aliases" and parts[2] == "forget" and method == "DELETE":
delete_alias(parts[1])
return no_content()
if parts == ["alias-recipients"] and method == "POST":
payload = body_json(event)
local = str(payload.get("alias_id") or "").lower()
return response(200, {"data": addy_alias(change_alias(local, {"forward_to": addy_forward(payload.get("recipient_ids"))}))})
if len(parts) == 3 and parts[0] == "aliases" and parts[2] == "bulk" and method == "POST":
return response(200, addy_bulk(parts[1], body_json(event)))
# Only the forwarding address of an alias may reply from it: in addy.io
# words its recipients are always "attached only".
if parts == ["attached-recipients-only"] and method == "POST":
return response(200, {"data": addy_alias(existing_alias(str(body_json(event).get("id") or "")))})
if parts == ["recipients"] and method == "POST":
return response(201, {"data": add_recipient(body_json(event).get("email"))})
if len(parts) == 2 and parts[0] == "recipients" and method == "PATCH":
return response(200, {"data": describe_recipient(parts[1], body_json(event).get("description"))})
if len(parts) == 2 and parts[0] == "recipients" and method == "DELETE":
remove_recipient(parts[1])
return no_content()
if parts == ["recipients", "email", "resend"] and method == "POST":
address = known_recipient(str(body_json(event).get("recipient_id") or ""))
if addy_mail():
ses_v1().verify_email_identity(EmailAddress=address)
return response(200, {"data": addy_recipient(address)})
if parts == ["domains"] and method == "GET":
return response(200, {"data": [addy_domain(domain) for domain in alias_domains()]})
if len(parts) == 2 and parts[0] == "domains" and method == "GET":
if parts[1].lower() not in alias_domains():
raise ApiError(404, "not_found")
return response(200, {"data": addy_domain(parts[1].lower())})
if parts and parts[0] in ADDY_UNSUPPORTED:
raise ApiError(422, "not_supported")
raise ApiError(404, "not_found")
# What the addy.io apps can switch that this relay does not have: recipient
# encryption and permissions, own domains and usernames, rules, a blocklist,
# labels, restoring deleted aliases. They get a 422 with a message the apps
# show, not a 404.
ADDY_UNSUPPORTED = {
"active-recipients", "allowed-recipients", "encrypted-recipients", "recipient-keys", "protected-headers-recipients",
"inline-encrypted-recipients", "remove-pgp-keys-recipients", "remove-pgp-signatures-recipients",
"domains", "active-domains", "catch-all-domains", "shared-with-family-domains",
"usernames", "active-usernames", "catch-all-usernames", "loginable-usernames",
"rules", "active-rules", "reorder-rules", "blocklist", "labels", "failed-deliveries",
"attached-recipients-only", "aliases",
}
# Bulk actions on aliases (POST /aliases/<action>/bulk with "ids"): the ones
# done come back in "ids". Forget is delete; nothing is ever deleted softly,
# so there is nothing to restore.
def addy_bulk(action, payload):
ids = [str(value).lower() for value in (payload.get("ids") or []) if isinstance(value, str)][:100]
found = [item for item in (get_alias(local) for local in ids) if item]
if action == "get":
return {"data": [addy_alias(item) for item in found]}
changes = {"activate": {"active": True}, "deactivate": {"active": False}, "pin": {"pinned": True}, "unpin": {"pinned": False}}
if action in changes:
for item in found:
change_alias(item["sk"], changes[action])
elif action in ("delete", "forget"):
for item in found:
delete_alias(item["sk"])
elif action == "recipients":
forward = addy_forward(payload.get("recipient_ids"))
for item in found:
change_alias(item["sk"], {"forward_to": forward})
elif action == "labels":
pass
elif action == "restore":
found = []
else:
raise ApiError(404, "not_found")
done = [item["sk"] for item in found]
return {"message": f"{len(done)} aliases: {action}", "ids": done}
# The forwarding address from addy.io recipient_ids: the first one; none
# means the default address.
def addy_forward(recipient_ids):
ids = recipient_ids if isinstance(recipient_ids, list) else []
if not ids:
return ""
address = known_recipient(str(ids[0]))
return "" if address == forward_to() else address
# Recipients are the forwarding addresses: the default one, those aliases
# forward to, and those added through the API (RECIPIENT items, with an
# optional description).
def recipient_addresses():
return {forward_to()} | {destination(item) for item in all_aliases()} | {item["sk"] for item in query_all("RECIPIENT")}
def known_recipient(value):
address = str(value or "").strip().lower()
if address not in recipient_addresses():
raise ApiError(404, "recipient_not_found")
return address
def add_recipient(value):
address = clean_forward(value)
if not address:
raise ApiError(422, "address_required")
if address not in recipient_addresses():
if recipient_cap():
raise ApiError(422, "too_many_recipients")
table().put_item(Item={"pk": "RECIPIENT", "sk": address, "created_at": now_iso()})
if addy_mail():
# SES mails a link while the account is in the sandbox.
ses_v1().verify_email_identity(EmailAddress=address)
return addy_recipient(address)
def describe_recipient(value, description):
address = known_recipient(value)
if description is not None and not isinstance(description, str):
raise ApiError(422, "bad_comment_type")
text = (description or "").strip()[:200]
table().update_item(
Key={"pk": "RECIPIENT", "sk": address},
UpdateExpression="SET #description = :description, #updated_at = :now",
ExpressionAttributeNames={"#description": "description", "#updated_at": "updated_at"},
ExpressionAttributeValues={":description": text, ":now": now_iso()},
)
return addy_recipient(address)