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
|
import argparse
import collections
import datetime
import os
import requests
import string
import sys
import yaml
Session = collections.namedtuple('Session', 'headers account_id api_url download_template')
def get_session(token):
headers = {'Authorization': 'Bearer ' + token}
r = requests.get('https://api.fastmail.com/.well-known/jmap', headers=headers)
[account_id] = list(r.json()['accounts'])
api_url = r.json()['apiUrl']
download_template = r.json()['downloadUrl']
return Session(headers, account_id, api_url, download_template)
Email = collections.namedtuple('Email', 'id blob_id date subject')
def query(session, start, end):
json_request = {
'using': ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
'methodCalls': [
[
'Email/query',
{
'accountId': session.account_id,
'sort': [{'property': 'receivedAt', 'isAscending': False}],
'filter': {
'after': start.isoformat() + 'Z',
'before': end.isoformat() + 'Z',
},
'limit': 50,
},
'0',
],
[
'Email/get',
{
'accountId': session.account_id,
'#ids': {
'name': 'Email/query',
'path': '/ids/*',
'resultOf': '0',
},
'properties': ['blobId', 'receivedAt', 'subject'],
},
'1',
],
],
}
while True:
full_response = requests.post(
session.api_url, json=json_request, headers=session.headers
).json()
if any(x[0].lower() == 'error' for x in full_response['methodResponses']):
sys.exit(f'Error received from server: {full_response!r}')
response = [x[1] for x in full_response['methodResponses']]
if not response[0]['ids']:
return
for item in response[1]['list']:
date = datetime.datetime.fromisoformat(item['receivedAt'].rstrip('Z'))
yield Email(item['id'], item['blobId'], date, item['subject'])
# Set anchor to get the next set of emails.
query_request = json_request['methodCalls'][0][1]
query_request['anchor'] = response[0]['ids'][-1]
query_request['anchorOffset'] = 1
def email_filename(email):
subject = (
email.subject.translate(str.maketrans('', '', string.punctuation))[:50]
if email.subject else '')
date = email.date.strftime('%Y%m%d_%H%M%S')
return f'{date}_{email.id}_{subject.strip()}.eml'
def download_email(session, email, folder):
r = requests.get(
session.download_template.format(
accountId=session.account_id,
blobId=email.blob_id,
name='email',
type='application/octet-stream',
),
headers=session.headers,
)
with open(os.path.join(folder, email_filename(email)), 'wb') as fh:
fh.write(r.content)
if __name__ == '__main__':
# Parse args.
parser = argparse.ArgumentParser(description='Backup jmap mail')
parser.add_argument('--config', help='Path to config file', nargs=1)
args = parser.parse_args()
# Read config.
with open(args.config[0], 'r') as fh:
config = yaml.safe_load(fh)
# Compute window.
session = get_session(config['token'])
delay_hours = config.get('delay_hours', 24)
end_window = datetime.datetime.utcnow().replace(microsecond=0) - datetime.timedelta(
hours=delay_hours
)
# On first run, 'last_end_time' wont exist; download the most recent week.
start_window = config.get('last_end_time', end_window - datetime.timedelta(weeks=1))
folder = config['folder']
# Do backup.
num_results = 0
for email in query(session, start_window, end_window):
# We want our search window to be exclusive of the right endpoint.
# It should be this way in the server, according to the spec, but
# Fastmail's query implementation is inclusive of both endpoints.
if email.date == end_window:
continue
download_email(session, email, folder)
num_results += 1
print(f'Archived {num_results} emails')
# Write config
config['last_end_time'] = end_window
with open(args.config[0], 'w') as fh:
yaml.dump(config, fh)
|