├── LICENSE ├── README.rst └── imapcopy.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Christoph Heer 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of {{ project }} nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | IMAP Copy 2 | ========= 3 | 4 | This is a very simple tool to copy folders from one IMAP server to another server. 5 | 6 | 7 | Example: 8 | 9 | The example below copies all messages from the INBOX of your other server into 10 | the 'OTHER-SERVER/Inbox' folder of Gmail. 11 | 12 | :: 13 | 14 | python imapcopy.py "imap.otherserver.com.au:993" "username:password" \ 15 | "imap.googlemail.com:993" "username@gmail.com:password" \ 16 | "INBOX" "OTHER-SERVER/Inbox" --verbose 17 | 18 | Since Gmail terribly throttles uploading and downloading mails over IMAP, you 19 | may find the 'skip' and 'limit' options handy. If Gmail disconnected you after 20 | copying 123 emails out of your total 1000 emails in the example shown above, 21 | you may use the following command to resume copying skipping the first 123 22 | messages. 23 | 24 | :: 25 | 26 | python imapcopy.py "imap.otherserver.com.au:993" "username:password" \ 27 | "imap.googlemail.com:993" "username@gmail.com:password" \ 28 | "INBOX" "OTHER-SERVER/Inbox" --skip 123 29 | 30 | Similarly the 'limit' option allows you to copy only the N number of messages 31 | excluding the skipped messages. For example, the following command will copy 32 | message no. 124 to 223 into Gmail. 33 | 34 | :: 35 | 36 | python imapcopy.py "imap.otherserver.com.au:993" "username:password" \ 37 | "imap.googlemail.com:993" "username@gmail.com:password" \ 38 | "INBOX" "OTHER-SERVER/Inbox" --skip 123 --limit 100 39 | 40 | There is also 'recurse' option that copies contents of folders with all of 41 | its subfolders. Also if you replace source mailbox with empty string, it will 42 | copy all contents of that mailbox: 43 | 44 | :: 45 | 46 | python imapcopy.py "imap.otherserver.com.au:993" "username:password" \ 47 | "imap.googlemail.com:993" "username@gmail.com:password" \ 48 | "" "OTHER-SERVER" --recurse 49 | 50 | Usage: 51 | 52 | :: 53 | 54 | usage: imapcopy.py [-h] [-q] [-v] 55 | source source-auth destination destination-auth mailboxes 56 | [mailboxes ...] 57 | 58 | positional arguments: 59 | source Source host ex. imap.googlemail.com:993 60 | source-auth Source host authentication ex. username@host.de:password 61 | destination Destination host ex. imap.otherhoster.com:993 62 | destination-auth Destination host authentication ex. 63 | username@host.de:password 64 | mailboxes List of mailboxes alternate between source mailbox and 65 | destination mailbox. 66 | 67 | optional arguments: 68 | -h, --help show this help message and exit 69 | -c, --create-mailboxes 70 | Create the mailboxes on destination 71 | -r, --recurse Recurse into submailboxes 72 | -q, --quiet ppsssh... be quiet. (no output) 73 | -v, --verbose more output please (debug level) 74 | -s N, --skip N skip the first N message(s) 75 | -l N, --limit N only copy N number of message(s) 76 | 77 | Only tested on Python 2.7. 78 | -------------------------------------------------------------------------------- /imapcopy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | imapcopy 4 | 5 | Simple tool to copy folders from one IMAP server to another server. 6 | 7 | 8 | :copyright: (c) 2013 by Christoph Heer. 9 | :license: BSD, see LICENSE for more details. 10 | """ 11 | 12 | import sys 13 | import hashlib 14 | import imaplib 15 | import logging 16 | import argparse 17 | import email 18 | 19 | 20 | class IMAP_Copy(object): 21 | source = { 22 | 'host': 'localhost', 23 | 'port': 993 24 | } 25 | source_auth = () 26 | destination = { 27 | 'host': 'localhost', 28 | 'port': 993 29 | } 30 | destination_auth = () 31 | mailbox_mapping = [] 32 | 33 | def __init__(self, source_server, destination_server, mailbox_mapping, 34 | source_auth=(), destination_auth=(), create_mailboxes=False, 35 | recurse=False, skip=0, limit=0): 36 | 37 | self.logger = logging.getLogger("IMAP_Copy") 38 | 39 | self.source.update(source_server) 40 | self.destination.update(destination_server) 41 | self.source_auth = source_auth 42 | self.destination_auth = destination_auth 43 | 44 | self.mailbox_mapping = mailbox_mapping 45 | self.create_mailboxes = create_mailboxes 46 | 47 | self.skip = skip 48 | self.limit = limit 49 | 50 | self.recurse = recurse 51 | 52 | def _connect(self, target): 53 | data = getattr(self, target) 54 | auth = getattr(self, target + "_auth") 55 | 56 | self.logger.info("Connect to %s (%s)" % (target, data['host'])) 57 | if data['port'] == 993: 58 | connection = imaplib.IMAP4_SSL(data['host'], data['port']) 59 | else: 60 | connection = imaplib.IMAP4(data['host'], data['port']) 61 | 62 | if len(auth) > 0: 63 | self.logger.info("Authenticate at %s" % target) 64 | connection.login(*auth) 65 | 66 | setattr(self, '_conn_%s' % target, connection) 67 | self.logger.info("%s connection established" % target) 68 | # Detecting delimiter on destination server 69 | code, mailbox_list = connection.list() 70 | self.delimiter = mailbox_list[0].split('"')[1] 71 | 72 | def connect(self): 73 | self._connect('source') 74 | self._connect('destination') 75 | 76 | def _disconnect(self, target): 77 | if not hasattr(self, '_conn_%s' % target): 78 | return 79 | 80 | connection = getattr(self, '_conn_%s' % target) 81 | if connection.state == 'SELECTED': 82 | connection.close() 83 | self.logger.info("Close mailbox on %s" % target) 84 | 85 | self.logger.info("Disconnect from %s server" % target) 86 | connection.logout() 87 | delattr(self, '_conn_%s' % target) 88 | 89 | def disconnect(self): 90 | self._disconnect('source') 91 | self._disconnect('destination') 92 | 93 | def copy(self, source_mailbox, destination_mailbox, skip, limit, recurse_level=0): 94 | if self.recurse: 95 | self.logger.info("Getting list of mailboxes under %s" % source_mailbox) 96 | connection = self._conn_source 97 | typ, data = connection.list(source_mailbox) 98 | for d in data: 99 | if d: 100 | new_source_mailbox = d.split('"')[3] # Getting submailbox name 101 | if new_source_mailbox.count('/') == recurse_level: 102 | self.logger.info("Recursing into %s" % new_source_mailbox) 103 | new_destination_mailbox = new_source_mailbox.split("/")[recurse_level] 104 | self.copy(new_source_mailbox, destination_mailbox + self.delimiter + new_destination_mailbox, 105 | skip, limit, recurse_level + 1) 106 | 107 | # There should be no files stored in / so we are bailing out 108 | if source_mailbox == '': 109 | return 110 | 111 | # Connect to source and open mailbox 112 | status, data = self._conn_source.select(source_mailbox, True) 113 | if status != "OK": 114 | self.logger.error("Couldn't open source mailbox %s" % 115 | source_mailbox) 116 | sys.exit(2) 117 | 118 | # Connect to destination and open or create mailbox 119 | status, data = self._conn_destination.select(destination_mailbox) 120 | if status != "OK" and not self.create_mailboxes: 121 | self.logger.error("Couldn't open destination mailbox %s" % 122 | destination_mailbox) 123 | sys.exit(2) 124 | else: 125 | self.logger.info("Create destination mailbox %s" % 126 | destination_mailbox) 127 | self._conn_destination.create(destination_mailbox) 128 | self._conn_destination.subscribe(destination_mailbox) 129 | status, data = self._conn_destination.select(destination_mailbox) 130 | 131 | # Look for mails 132 | self.logger.info("Looking for mails in %s" % source_mailbox) 133 | status, data = self._conn_source.search(None, 'ALL') 134 | data = data[0].split() 135 | mail_count = len(data) 136 | 137 | self.logger.info("Start copy %s => %s (%d mails)" % ( 138 | source_mailbox, destination_mailbox, mail_count)) 139 | 140 | progress_count = 0 141 | copy_count = 0 142 | 143 | for msg_num in data: 144 | progress_count += 1 145 | if progress_count <= skip: 146 | self.logger.info("Skipping mail %d of %d" % ( 147 | progress_count, mail_count)) 148 | continue 149 | else: 150 | status, data = self._conn_source.fetch(msg_num, '(RFC822 FLAGS)') 151 | message = data[0][1] 152 | flags = data[1][8:][:-2] # Not perfect.. Waiting for bug reports 153 | msg = email.message_from_string(message); 154 | msgDate = email.utils.parsedate(msg['Date']) 155 | 156 | self._conn_destination.append( 157 | destination_mailbox, flags, msgDate, message 158 | ) 159 | 160 | copy_count += 1 161 | message_md5 = hashlib.md5(message).hexdigest() 162 | 163 | self.logger.info("Copy mail %d of %d (copy_count=%d, md5(message)=%s)" % ( 164 | progress_count, mail_count, copy_count, message_md5)) 165 | 166 | if limit > 0 and copy_count >= limit: 167 | self.logger.info("Copy limit %d reached (copy_count=%d)" % ( 168 | limit, copy_count)) 169 | break 170 | 171 | self.logger.info("Copy complete %s => %s (%d out of %d mails copied)" % ( 172 | source_mailbox, destination_mailbox, copy_count, mail_count)) 173 | 174 | def run(self): 175 | try: 176 | self.connect() 177 | for source_mailbox, destination_mailbox in self.mailbox_mapping: 178 | self.copy(source_mailbox, destination_mailbox, self.skip, self.limit) 179 | finally: 180 | self.disconnect() 181 | 182 | 183 | def main(): 184 | parser = argparse.ArgumentParser() 185 | parser.add_argument('source', 186 | help="Source host ex. imap.googlemail.com:993") 187 | parser.add_argument('source_auth', metavar='source-auth', 188 | help="Source host authentication ex. " 189 | "username@host.de:password") 190 | 191 | parser.add_argument('destination', 192 | help="Destination host ex. imap.otherhoster.com:993") 193 | parser.add_argument('destination_auth', metavar='destination-auth', 194 | help="Destination host authentication ex. " 195 | "username@host.de:password") 196 | 197 | parser.add_argument('mailboxes', type=str, nargs='+', 198 | help='List of mailboxes alternate between source ' 199 | 'mailbox and destination mailbox.') 200 | parser.add_argument('-c', '--create-mailboxes', dest='create_mailboxes', 201 | action="store_true", default=False, 202 | help='Create the mailboxes on destination') 203 | parser.add_argument('-r', '--recurse', dest='recurse', action="store_true", 204 | default=False, help='Recurse into submailboxes') 205 | parser.add_argument('-q', '--quiet', action="store_true", default=False, 206 | help='ppsssh... be quiet. (no output)') 207 | parser.add_argument('-v', '--verbose', action="store_true", default=False, 208 | help='more output please (debug level)') 209 | 210 | def check_negative(value): 211 | ivalue = int(value) 212 | if ivalue < 0: 213 | raise argparse.ArgumentTypeError("%s is an invalid positive integer value" % value) 214 | return ivalue 215 | 216 | parser.add_argument("-s", "--skip", default=0, metavar="N", type=check_negative, 217 | help='skip the first N message(s)') 218 | parser.add_argument("-l", "--limit", default=0, metavar="N", type=check_negative, 219 | help='only copy N number of message(s)') 220 | 221 | args = parser.parse_args() 222 | 223 | _source = args.source.split(':') 224 | source = {'host': _source[0]} 225 | if len(_source) > 1: 226 | source['port'] = int(_source[1]) 227 | 228 | _destination = args.destination.split(':') 229 | destination = {'host': _destination[0]} 230 | if len(_destination) > 1: 231 | destination['port'] = int(_destination[1]) 232 | 233 | source_auth = tuple(args.source_auth.split(':')) 234 | destination_auth = tuple(args.destination_auth.split(':')) 235 | 236 | if len(args.mailboxes) % 2 != 0: 237 | print "Not valid count of mailboxes!" 238 | sys.exit(1) 239 | 240 | mailbox_mapping = zip(args.mailboxes[::2], args.mailboxes[1::2]) 241 | 242 | imap_copy = IMAP_Copy(source, destination, mailbox_mapping, source_auth, 243 | destination_auth, create_mailboxes=args.create_mailboxes, 244 | recurse=args.recurse, skip=args.skip, limit=args.limit) 245 | 246 | streamHandler = logging.StreamHandler() 247 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 248 | streamHandler.setFormatter(formatter) 249 | imap_copy.logger.addHandler(streamHandler) 250 | 251 | if not args.quiet: 252 | streamHandler.setLevel(logging.INFO) 253 | imap_copy.logger.setLevel(logging.INFO) 254 | if args.verbose: 255 | streamHandler.setLevel(logging.DEBUG) 256 | imap_copy.logger.setLevel(logging.DEBUG) 257 | 258 | try: 259 | imap_copy.run() 260 | except KeyboardInterrupt: 261 | imap_copy.disconnect() 262 | 263 | 264 | if __name__ == '__main__': 265 | main() 266 | --------------------------------------------------------------------------------