Coverage for paperless_mail/models.py: 98%

98 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 09:07 +0000

1from django.db import models 

2from django.utils import timezone 

3from django.utils.translation import gettext_lazy as _ 

4 

5import documents.models as document_models 

6 

7 

8class MailAccount(document_models.ModelWithOwner): 

9 class Meta: 

10 verbose_name = _("mail account") 

11 verbose_name_plural = _("mail accounts") 

12 

13 class ImapSecurity(models.IntegerChoices): 

14 NONE = 1, _("No encryption") 

15 SSL = 2, _("Use SSL") 

16 STARTTLS = 3, _("Use STARTTLS") 

17 

18 class MailAccountType(models.IntegerChoices): 

19 IMAP = 1, _("IMAP") 

20 GMAIL_OAUTH = 2, _("Gmail OAuth") 

21 OUTLOOK_OAUTH = 3, _("Outlook OAuth") 

22 

23 name = models.CharField(_("name"), max_length=256, unique=True) 

24 

25 imap_server = models.CharField(_("IMAP server"), max_length=256) 

26 

27 imap_port = models.PositiveIntegerField( 

28 _("IMAP port"), 

29 blank=True, 

30 null=True, 

31 help_text=_( 

32 "This is usually 143 for unencrypted and STARTTLS " 

33 "connections, and 993 for SSL connections.", 

34 ), 

35 ) 

36 

37 imap_security = models.PositiveSmallIntegerField( 

38 _("IMAP security"), 

39 choices=ImapSecurity.choices, 

40 default=ImapSecurity.SSL, 

41 ) 

42 

43 username = models.CharField(_("username"), max_length=256) 

44 

45 password = models.TextField(_("password")) 

46 

47 is_token = models.BooleanField(_("Is token authentication"), default=False) 

48 

49 character_set = models.CharField( 

50 _("character set"), 

51 max_length=256, 

52 default="UTF-8", 

53 help_text=_( 

54 "The character set to use when communicating with the " 

55 "mail server, such as 'UTF-8' or 'US-ASCII'.", 

56 ), 

57 ) 

58 

59 account_type = models.PositiveSmallIntegerField( 

60 _("account type"), 

61 choices=MailAccountType.choices, 

62 default=MailAccountType.IMAP, 

63 ) 

64 

65 refresh_token = models.TextField( 

66 _("refresh token"), 

67 blank=True, 

68 null=True, 

69 help_text=_( 

70 "The refresh token to use for token authentication e.g. with oauth2.", 

71 ), 

72 ) 

73 

74 expiration = models.DateTimeField( 

75 _("expiration"), 

76 blank=True, 

77 null=True, 

78 help_text=_( 

79 "The expiration date of the refresh token. ", 

80 ), 

81 ) 

82 

83 def __str__(self): 

84 return self.name 

85 

86 

87class MailRule(document_models.ModelWithOwner): 

88 class Meta: 

89 verbose_name = _("mail rule") 

90 verbose_name_plural = _("mail rules") 

91 constraints = [ 

92 models.UniqueConstraint( 

93 fields=["name", "owner"], 

94 name="%(app_label)s_%(class)s_unique_name_owner", 

95 ), 

96 models.UniqueConstraint( 

97 name="%(app_label)s_%(class)s_name_unique", 

98 fields=["name"], 

99 condition=models.Q(owner__isnull=True), 

100 ), 

101 ] 

102 

103 class ConsumptionScope(models.IntegerChoices): 

104 ATTACHMENTS_ONLY = 1, _("Only process attachments.") 

105 EML_ONLY = 2, _("Process full Mail (with embedded attachments in file) as .eml") 

106 EVERYTHING = ( 

107 3, 

108 _( 

109 "Process full Mail (with embedded attachments in file) as .eml " 

110 "+ process attachments as separate documents", 

111 ), 

112 ) 

113 

114 class AttachmentProcessing(models.IntegerChoices): 

115 ATTACHMENTS_ONLY = 1, _("Only process attachments.") 

116 EVERYTHING = 2, _("Process all files, including 'inline' attachments.") 

117 

118 class PdfLayout(models.IntegerChoices): 

119 DEFAULT = 0, _("System default") 

120 TEXT_HTML = 1, _("Text, then HTML") 

121 HTML_TEXT = 2, _("HTML, then text") 

122 HTML_ONLY = 3, _("HTML only") 

123 TEXT_ONLY = 4, _("Text only") 

124 

125 class MailAction(models.IntegerChoices): 

126 DELETE = 1, _("Delete") 

127 MOVE = 2, _("Move to specified folder") 

128 MARK_READ = 3, _("Mark as read, don't process read mails") 

129 FLAG = 4, _("Flag the mail, don't process flagged mails") 

130 TAG = 5, _("Tag the mail with specified tag, don't process tagged mails") 

131 

132 class TitleSource(models.IntegerChoices): 

133 FROM_SUBJECT = 1, _("Use subject as title") 

134 FROM_FILENAME = 2, _("Use attachment filename as title") 

135 NONE = 3, _("Do not assign title from rule") 

136 

137 class CorrespondentSource(models.IntegerChoices): 

138 FROM_NOTHING = 1, _("Do not assign a correspondent") 

139 FROM_EMAIL = 2, _("Use mail address") 

140 FROM_NAME = 3, _("Use name (or mail address if not available)") 

141 FROM_CUSTOM = 4, _("Use correspondent selected below") 

142 

143 name = models.CharField(_("name"), max_length=256) 

144 

145 order = models.SmallIntegerField(_("order"), default=0) 

146 

147 account = models.ForeignKey( 

148 MailAccount, 

149 related_name="rules", 

150 on_delete=models.CASCADE, 

151 verbose_name=_("account"), 

152 ) 

153 

154 enabled = models.BooleanField(_("enabled"), default=True) 

155 

156 folder = models.CharField( 

157 _("folder"), 

158 default="INBOX", 

159 max_length=256, 

160 help_text=_( 

161 "Subfolders must be separated by a delimiter, often a dot ('.') or" 

162 " slash ('/'), but it varies by mail server.", 

163 ), 

164 ) 

165 

166 filter_from = models.CharField( 

167 _("filter from"), 

168 max_length=256, 

169 null=True, 

170 blank=True, 

171 ) 

172 

173 filter_to = models.CharField( 

174 _("filter to"), 

175 max_length=256, 

176 null=True, 

177 blank=True, 

178 ) 

179 

180 filter_subject = models.CharField( 

181 _("filter subject"), 

182 max_length=256, 

183 null=True, 

184 blank=True, 

185 ) 

186 

187 filter_body = models.CharField( 

188 _("filter body"), 

189 max_length=256, 

190 null=True, 

191 blank=True, 

192 ) 

193 

194 filter_attachment_filename_include = models.CharField( 

195 _("filter attachment filename inclusive"), 

196 max_length=256, 

197 null=True, 

198 blank=True, 

199 help_text=_( 

200 "Only consume documents which entirely match this " 

201 "filename if specified. Wildcards such as *.pdf or " 

202 "*invoice* are allowed. Case insensitive.", 

203 ), 

204 ) 

205 

206 filter_attachment_filename_exclude = models.CharField( 

207 _("filter attachment filename exclusive"), 

208 max_length=256, 

209 null=True, 

210 blank=True, 

211 help_text=_( 

212 "Do not consume documents which entirely match this " 

213 "filename if specified. Wildcards such as *.pdf or " 

214 "*invoice* are allowed. Case insensitive.", 

215 ), 

216 ) 

217 

218 maximum_age = models.PositiveSmallIntegerField( 

219 _("maximum age"), 

220 default=30, 

221 help_text=_("Specified in days."), 

222 ) 

223 

224 attachment_type = models.PositiveSmallIntegerField( 

225 _("attachment type"), 

226 choices=AttachmentProcessing.choices, 

227 default=AttachmentProcessing.ATTACHMENTS_ONLY, 

228 help_text=_( 

229 "Inline attachments include embedded images, so it's best " 

230 "to combine this option with a filename filter.", 

231 ), 

232 ) 

233 

234 consumption_scope = models.PositiveSmallIntegerField( 

235 _("consumption scope"), 

236 choices=ConsumptionScope.choices, 

237 default=ConsumptionScope.ATTACHMENTS_ONLY, 

238 ) 

239 

240 pdf_layout = models.PositiveSmallIntegerField( 

241 _("pdf layout"), 

242 choices=PdfLayout.choices, 

243 default=PdfLayout.DEFAULT, 

244 ) 

245 

246 action = models.PositiveSmallIntegerField( 

247 _("action"), 

248 choices=MailAction.choices, 

249 default=MailAction.MARK_READ, 

250 ) 

251 

252 action_parameter = models.CharField( 

253 _("action parameter"), 

254 max_length=256, 

255 blank=True, 

256 null=True, 

257 help_text=_( 

258 "Additional parameter for the action selected above, " 

259 "i.e., " 

260 "the target folder of the move to folder action. " 

261 "Subfolders must be separated by dots.", 

262 ), 

263 ) 

264 

265 assign_title_from = models.PositiveSmallIntegerField( 

266 _("assign title from"), 

267 choices=TitleSource.choices, 

268 default=TitleSource.FROM_SUBJECT, 

269 ) 

270 

271 assign_tags = models.ManyToManyField( 

272 document_models.Tag, 

273 blank=True, 

274 verbose_name=_("assign this tag"), 

275 ) 

276 

277 assign_document_type = models.ForeignKey( 

278 document_models.DocumentType, 

279 null=True, 

280 blank=True, 

281 on_delete=models.SET_NULL, 

282 verbose_name=_("assign this document type"), 

283 ) 

284 

285 assign_correspondent_from = models.PositiveSmallIntegerField( 

286 _("assign correspondent from"), 

287 choices=CorrespondentSource.choices, 

288 default=CorrespondentSource.FROM_NOTHING, 

289 ) 

290 

291 assign_correspondent = models.ForeignKey( 

292 document_models.Correspondent, 

293 null=True, 

294 blank=True, 

295 on_delete=models.SET_NULL, 

296 verbose_name=_("assign this correspondent"), 

297 ) 

298 

299 assign_owner_from_rule = models.BooleanField( 

300 _("Assign the rule owner to documents"), 

301 default=True, 

302 ) 

303 

304 stop_processing = models.BooleanField( 

305 _("Stop processing further rules"), 

306 default=False, 

307 help_text=_( 

308 "If True, no further rules will be processed after this one if any document is queued.", 

309 ), 

310 ) 

311 

312 def __str__(self): 

313 return f"{self.account.name}.{self.name}" 

314 

315 

316class ProcessedMail(document_models.ModelWithOwner): 

317 rule = models.ForeignKey( 

318 MailRule, 

319 null=False, 

320 blank=False, 

321 on_delete=models.CASCADE, 

322 editable=False, 

323 ) 

324 

325 folder = models.CharField( 

326 _("folder"), 

327 null=False, 

328 blank=False, 

329 max_length=256, 

330 editable=False, 

331 ) 

332 

333 uid = models.CharField( 

334 _("uid"), 

335 null=False, 

336 blank=False, 

337 max_length=256, 

338 editable=False, 

339 ) 

340 

341 uid_validity = models.CharField( 

342 _("uid validity"), 

343 null=True, 

344 blank=True, 

345 max_length=64, 

346 editable=False, 

347 ) 

348 

349 subject = models.CharField( 

350 _("subject"), 

351 null=False, 

352 blank=False, 

353 max_length=256, 

354 editable=False, 

355 ) 

356 

357 received = models.DateTimeField( 

358 _("received"), 

359 null=False, 

360 blank=False, 

361 editable=False, 

362 ) 

363 

364 processed = models.DateTimeField( 

365 _("processed"), 

366 default=timezone.now, 

367 editable=False, 

368 ) 

369 

370 status = models.CharField( 

371 _("status"), 

372 null=False, 

373 blank=False, 

374 max_length=256, 

375 editable=False, 

376 ) 

377 

378 error = models.TextField( 

379 _("error"), 

380 null=True, 

381 blank=True, 

382 editable=False, 

383 )