utf8: implement utf8foldernames option

If utf8foldernames is enabled on account level all folder names read
from the IMAP server will immediately be reencoded to UTF-8. Names
will be treated as UTF-8 as long as the IMAP server isn't contacted again,
for which they are reencoded to IMAP4-UTF-7.

This means that any further processing such as nametrans, folderfilter
etc. will act upon the UTF-8 names, which will have to be documented
carefully.

NOTE 1:
GMail repositories and folders inherit from the IMAP... classes, so I don't
know yet if these changes have ugly side-effects. But web research suggests
that GMail IMAP folders are equally encoded in UTF-7 so that should work
identically here and incorporate the same improvements.

NOTE 2:
I could not test the behaviour with idlefolders as I didn't get this option
to work at all, not even with the latest stable version.

NOTE 3:
I *did* test to sync an IMAP repository against another IMAP repository.

Signed-off-by: Urs Liska <git@ursliska.de>
Signed-off-by: Nicolas Sebrecht <nicolas.s-dev@laposte.net>
This commit is contained in:
Urs Liska 2017-10-02 01:26:29 +02:00 committed by Nicolas Sebrecht
parent 14d83dbf48
commit 36d726763d
6 changed files with 42 additions and 25 deletions

View File

@ -69,6 +69,8 @@ class Account(CustomConfig.ConfigHelperMixin):
self.name = name self.name = name
self.metadatadir = config.getmetadatadir() self.metadatadir = config.getmetadatadir()
self.localeval = config.getlocaleval() self.localeval = config.getlocaleval()
# Store utf-8 support as a property of Account object
self.utf_8_support = self.getconfboolean('utf8foldernames', False)
# Current :mod:`offlineimap.ui`, can be used for logging: # Current :mod:`offlineimap.ui`, can be used for logging:
self.ui = getglobalui() self.ui = getglobalui()
self.refreshperiod = self.getconffloat('autorefresh', 0.0) self.refreshperiod = self.getconffloat('autorefresh', 0.0)
@ -360,7 +362,7 @@ class SyncableAccount(Account):
if not remotefolder.sync_this: if not remotefolder.sync_this:
self.ui.debug('', "Not syncing filtered folder '%s'" self.ui.debug('', "Not syncing filtered folder '%s'"
"[%s]"% (remotefolder, remoterepos)) "[%s]"% (remotefolder.getname(), remoterepos))
continue # Ignore filtered folder. continue # Ignore filtered folder.
# The remote folder names must not have the local sep char in # The remote folder names must not have the local sep char in
@ -378,7 +380,7 @@ class SyncableAccount(Account):
localfolder = self.get_local_folder(remotefolder) localfolder = self.get_local_folder(remotefolder)
if not localfolder.sync_this: if not localfolder.sync_this:
self.ui.debug('', "Not syncing filtered folder '%s'" self.ui.debug('', "Not syncing filtered folder '%s'"
"[%s]"% (localfolder, localfolder.repository)) "[%s]"% (localfolder.getname(), localfolder.repository))
continue # Ignore filtered folder. continue # Ignore filtered folder.
if not globals.options.singlethreading: if not globals.options.singlethreading:

View File

@ -41,10 +41,17 @@ MSGCOPY_NAMESPACE = 'MSGCOPY_'
class IMAPFolder(BaseFolder): class IMAPFolder(BaseFolder):
def __init__(self, imapserver, name, repository): def __init__(self, imapserver, name, repository, decode=True):
# FIXME: decide if unquoted name is from the responsability of the # decode the folder name from IMAP4_utf_7 to utf_8 if
# caller or not, but not both. # - utf8foldernames is enabled for the *account*
# - the decode argument is given
# (default True is used when the folder name is the result of
# querying the IMAP server, while False is used when creating
# a folder object from a locally available utf_8 name)
# In any case the given name is first dequoted.
name = imaputil.dequote(name) name = imaputil.dequote(name)
if decode and repository.account.utf_8_support:
name = imaputil.IMAP_utf8(name)
self.sep = imapserver.delim self.sep = imapserver.delim
super(IMAPFolder, self).__init__(name, repository) super(IMAPFolder, self).__init__(name, repository)
if repository.getdecodefoldernames(): if repository.getdecodefoldernames():
@ -69,7 +76,6 @@ class IMAPFolder(BaseFolder):
if self.repository.getidlefolders(): if self.repository.getidlefolders():
self.idle_mode = True self.idle_mode = True
def __selectro(self, imapobj, force=False): def __selectro(self, imapobj, force=False):
"""Select this folder when we do not need write access. """Select this folder when we do not need write access.
@ -80,9 +86,15 @@ class IMAPFolder(BaseFolder):
:param: Enforce new SELECT even if we are on that folder already. :param: Enforce new SELECT even if we are on that folder already.
:returns: raises :exc:`OfflineImapError` severity FOLDER on error""" :returns: raises :exc:`OfflineImapError` severity FOLDER on error"""
try: try:
imapobj.select(self.getfullname(), force = force) imapobj.select(self.getfullIMAPname(), force = force)
except imapobj.readonly: except imapobj.readonly:
imapobj.select(self.getfullname(), readonly = True, force = force) imapobj.select(self.getfullIMAPname(), readonly = True, force = force)
def getfullIMAPname(self):
name = self.getfullname()
if self.repository.account.utf_8_support:
name = imaputil.utf8_IMAP(name)
return name
# Interface from BaseFolder # Interface from BaseFolder
def suggeststhreads(self): def suggeststhreads(self):
@ -147,7 +159,7 @@ class IMAPFolder(BaseFolder):
imapobj = self.imapserver.acquireconnection() imapobj = self.imapserver.acquireconnection()
try: try:
# Select folder and get number of messages. # Select folder and get number of messages.
restype, imapdata = imapobj.select(self.getfullname(), True, restype, imapdata = imapobj.select(self.getfullIMAPname(), True,
True) True)
self.imapserver.releaseconnection(imapobj) self.imapserver.releaseconnection(imapobj)
except OfflineImapError as e: except OfflineImapError as e:
@ -213,7 +225,7 @@ class IMAPFolder(BaseFolder):
res_data.remove(0) res_data.remove(0)
return res_data return res_data
res_type, imapdata = imapobj.select(self.getfullname(), True, True) res_type, imapdata = imapobj.select(self.getfullIMAPname(), True, True)
if imapdata == [None] or imapdata[0] == '0': if imapdata == [None] or imapdata[0] == '0':
# Empty folder, no need to populate message list. # Empty folder, no need to populate message list.
return None return None
@ -630,7 +642,7 @@ class IMAPFolder(BaseFolder):
try: try:
# Select folder for append and make the box READ-WRITE. # Select folder for append and make the box READ-WRITE.
imapobj.select(self.getfullname()) imapobj.select(self.getfullIMAPname())
except imapobj.readonly: except imapobj.readonly:
# readonly exception. Return original uid to notify that # readonly exception. Return original uid to notify that
# we did not save the message. (see savemessage in Base.py) # we did not save the message. (see savemessage in Base.py)
@ -639,7 +651,7 @@ class IMAPFolder(BaseFolder):
# Do the APPEND. # Do the APPEND.
try: try:
(typ, dat) = imapobj.append(self.getfullname(), (typ, dat) = imapobj.append(self.getfullIMAPname(),
imaputil.flagsmaildir2imap(flags), date, content) imaputil.flagsmaildir2imap(flags), date, content)
# This should only catch 'NO' responses since append() # This should only catch 'NO' responses since append()
# will raise an exception for 'BAD' responses: # will raise an exception for 'BAD' responses:
@ -753,7 +765,7 @@ class IMAPFolder(BaseFolder):
fails_left = retry_num # Retry on dropped connection. fails_left = retry_num # Retry on dropped connection.
while fails_left: while fails_left:
try: try:
imapobj.select(self.getfullname(), readonly=True) imapobj.select(self.getfullIMAPname(), readonly=True)
res_type, data = imapobj.uid('fetch', uids, query) res_type, data = imapobj.uid('fetch', uids, query)
break break
except imapobj.abort as e: except imapobj.abort as e:
@ -813,7 +825,7 @@ class IMAPFolder(BaseFolder):
- field: field name to be stored/updated - field: field name to be stored/updated
- data: field contents - data: field contents
""" """
imapobj.select(self.getfullname()) imapobj.select(self.getfullIMAPname())
res_type, retdata = imapobj.uid('store', uid, field, data) res_type, retdata = imapobj.uid('store', uid, field, data)
if res_type != 'OK': if res_type != 'OK':
severity = OfflineImapError.ERROR.MESSAGE severity = OfflineImapError.ERROR.MESSAGE
@ -874,7 +886,7 @@ class IMAPFolder(BaseFolder):
imapobj = self.imapserver.acquireconnection() imapobj = self.imapserver.acquireconnection()
try: try:
try: try:
imapobj.select(self.getfullname()) imapobj.select(self.getfullIMAPname())
except imapobj.readonly: except imapobj.readonly:
self.ui.flagstoreadonly(self, uidlist, flags) self.ui.flagstoreadonly(self, uidlist, flags)
return return
@ -949,7 +961,7 @@ class IMAPFolder(BaseFolder):
imapobj = self.imapserver.acquireconnection() imapobj = self.imapserver.acquireconnection()
try: try:
try: try:
imapobj.select(self.getfullname()) imapobj.select(self.getfullIMAPname())
except imapobj.readonly: except imapobj.readonly:
self.ui.deletereadonly(self, uidlist) self.ui.deletereadonly(self, uidlist)
return return

View File

@ -40,8 +40,8 @@ class MappedIMAPFolder(IMAPFolder):
diskr2l: dict mapping message uids: self.r2l[remoteuid]=localuid diskr2l: dict mapping message uids: self.r2l[remoteuid]=localuid
diskl2r: dict mapping message uids: self.r2l[localuid]=remoteuid""" diskl2r: dict mapping message uids: self.r2l[localuid]=remoteuid"""
def __init__(self, *args, **kwargs): def __init__(self, imapserver, name, repository, decode=True):
IMAPFolder.__init__(self, *args, **kwargs) IMAPFolder.__init__(self, imapserver, name, repository, decode=False)
self.dryrun = self.config.getdefaultboolean("general", "dry-run", True) self.dryrun = self.config.getdefaultboolean("general", "dry-run", True)
self.maplock = Lock() self.maplock = Lock()
self.diskr2l, self.diskl2r = self._loadmaps() self.diskr2l, self.diskl2r = self._loadmaps()
@ -49,7 +49,7 @@ class MappedIMAPFolder(IMAPFolder):
# Representing the local IMAP Folder using local UIDs. # Representing the local IMAP Folder using local UIDs.
# XXX: This should be removed since we inherit from IMAPFolder. # XXX: This should be removed since we inherit from IMAPFolder.
# See commit 3ce514e92ba7 to know more. # See commit 3ce514e92ba7 to know more.
self._mb = IMAPFolder(*args, **kwargs) self._mb = IMAPFolder(imapserver, name, repository, decode=False)
def _getmapfilename(self): def _getmapfilename(self):
return os.path.join(self.repository.getmapdir(), return os.path.join(self.repository.getmapdir(),

View File

@ -797,7 +797,7 @@ class IdleThread(object):
localrepos = account.localrepos localrepos = account.localrepos
remoterepos = account.remoterepos remoterepos = account.remoterepos
statusrepos = account.statusrepos statusrepos = account.statusrepos
remotefolder = remoterepos.getfolder(self.folder) remotefolder = remoterepos.getfolder(self.folder, decode=False)
hook = account.getconf('presynchook', '') hook = account.getconf('presynchook', '')
account.callhook(hook) account.callhook(hook)

View File

@ -242,7 +242,7 @@ class BaseRepository(CustomConfig.ConfigHelperMixin):
# Get IMAPFolder and see if the reverse nametrans works fine. # Get IMAPFolder and see if the reverse nametrans works fine.
# TODO: getfolder() works only because we succeed in getting # TODO: getfolder() works only because we succeed in getting
# inexisting folders which I would like to change. Take care! # inexisting folders which I would like to change. Take care!
tmp_remotefolder = remote_repo.getfolder(remote_name) tmp_remotefolder = remote_repo.getfolder(remote_name, decode=False)
loop_name = tmp_remotefolder.getvisiblename().replace( loop_name = tmp_remotefolder.getvisiblename().replace(
remote_repo.getsep(), local_repo.getsep()) remote_repo.getsep(), local_repo.getsep())
if local_name != loop_name: if local_name != loop_name:

View File

@ -428,10 +428,10 @@ class IMAPRepository(BaseRepository):
# No strategy yielded a password! # No strategy yielded a password!
return None return None
def getfolder(self, foldername): def getfolder(self, foldername, decode=True):
"""Return instance of OfflineIMAP representative folder.""" """Return instance of OfflineIMAP representative folder."""
return self.getfoldertype()(self.imapserver, foldername, self) return self.getfoldertype()(self.imapserver, foldername, self, decode)
def getfoldertype(self): def getfoldertype(self):
return folder.IMAP.IMAPFolder return folder.IMAP.IMAPFolder
@ -488,7 +488,7 @@ class IMAPRepository(BaseRepository):
try: try:
for foldername in self.folderincludes: for foldername in self.folderincludes:
try: try:
imapobj.select(foldername, readonly=True) imapobj.select(imaputil.utf8_IMAP(foldername), readonly=True)
except OfflineImapError as e: except OfflineImapError as e:
# couldn't select this folderinclude, so ignore folder. # couldn't select this folderinclude, so ignore folder.
if e.severity > OfflineImapError.ERROR.FOLDER: if e.severity > OfflineImapError.ERROR.FOLDER:
@ -497,7 +497,7 @@ class IMAPRepository(BaseRepository):
'Invalid folderinclude:') 'Invalid folderinclude:')
continue continue
retval.append(self.getfoldertype()( retval.append(self.getfoldertype()(
self.imapserver, foldername, self)) self.imapserver, foldername, self, decode=False))
finally: finally:
self.imapserver.releaseconnection(imapobj) self.imapserver.releaseconnection(imapobj)
@ -555,6 +555,9 @@ class IMAPRepository(BaseRepository):
return return
imapobj = self.imapserver.acquireconnection() imapobj = self.imapserver.acquireconnection()
try: try:
if self.account.utf_8_support:
foldername = imaputil.utf8_IMAP(foldername)
result = imapobj.create(foldername) result = imapobj.create(foldername)
if result[0] != 'OK': if result[0] != 'OK':
raise OfflineImapError("Folder '%s'[%s] could not be created. " raise OfflineImapError("Folder '%s'[%s] could not be created. "